diff --git a/.github/workflows/sdk_build.yml b/.github/workflows/sdk_build.yml index 96eaa7d7d..ab88f9675 100644 --- a/.github/workflows/sdk_build.yml +++ b/.github/workflows/sdk_build.yml @@ -43,14 +43,90 @@ jobs: with: name: packages path: ${{ env.NUPKG_OUTDIR }} - - test: - name: Test .NET ${{ matrix.dotnet-version }} + discover-integration-v2-tests: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v1 + - id: set-matrix + run: | + # Find all csproj files matching the pattern recursively + PROJECTS=$(find test -name "Dapr.IntegrationTest.*.csproj" | jq -R -s -c 'split("\n")[:-1]') + echo "matrix=$PROJECTS" >> $GITHUB_OUTPUT + discover-unit-test-projects: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v1 + - id: set-matrix + run: | + # Find all csproj files matching the pattern recursively + PROJECTS=$(find test -name "Dapr.*.Test.csproj" ! -name "*E2E*" | jq -R -s -c 'split("\n")[:-1]') + echo "matrix=$PROJECTS" >> $GITHUB_OUTPUT + integration-tests-v2: + name: Integration Tests ${{ matrix.display-name }} / ${{ matrix.os }} - ${{ matrix.project}} + needs: discover-integration-v2-tests + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + dotnet-version: [ '8.0', '9.0' ] + os: [ubuntu-latest] + project: ${{ fromJson(needs.discover-integration-v2-tests.outputs.matrix) }} + include: + - dotnet-version: '8.0' + display-name: '.NET 8.0' + framework: 'net8' + prefix: 'net8' + install-version: '8.0.x' + - dotnet-version: '9.0' + display-name: '.NET 9.0' + framework: 'net9' + prefix: 'net9' + install-version: '9.0.x' + steps: + - uses: actions/checkout@v1 + - name: Parse release version + run: python ./.github/scripts/get_release_version.py + - name: Setup ${{ matrix.display-name }} + uses: actions/setup-dotnet@v3 + with: + dotnet-version: ${{ matrix.install-version }} + dotnet-quality: 'ga' # Prefer a GA release, but use the RC if not available + - name: Integration Tests + id: integration-tests-v2 + continue-on-error: false # proceed if tests fail to allow for the report generation in master or next step failure in PR + run: | + dotnet test "${{ matrix.project }}" \ + --configuration release \ + --framework ${{ matrix.framework }} \ + --logger "trx;LogFilePrefix=${{ matrix.prefix }}" \ + --logger "GitHubActions;report-warnings=false" \ + --results-directory "${{ github.workspace }}/TestResults" \ + /p:CollectCoverage=true \ + /p:CoverletOutputFormat=opencover \ + /p:GITHUB_ACTIONS=false + - name: Upload test coverage + uses: codecov/codecov-action@v1 + with: + flags: ${{ matrix.framework }} + - name: Parse Trx files + uses: NasAmin/trx-parser@v0.2.0 + id: trx-parser + if: github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository # does not work on PRs from forks + with: + TRX_PATH: ${{ github.workspace }}/TestResults + REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }} + unit-tests: + needs: discover-unit-test-projects runs-on: ubuntu-latest strategy: fail-fast: false matrix: dotnet-version: ['8.0', '9.0'] + project: ${{ fromJson(needs.discover-unit-test-projects.outputs.matrix) }} include: - dotnet-version: '8.0' display-name: '.NET 8.0' @@ -71,37 +147,19 @@ jobs: with: dotnet-version: ${{ matrix.install-version }} dotnet-quality: 'ga' # Prefer a GA release, but use the RC if not available - - name: Setup .NET 8 (required) - uses: actions/setup-dotnet@v3 - if: ${{ matrix.install-version != '8.0.x' }} - with: - dotnet-version: '8.0.x' - dotnet-quality: 'ga' - - name: Setup .NET 9 (required) - uses: actions/setup-dotnet@v3 - if: ${{ matrix.install-version != '9.0.x' }} - with: - dotnet-version: '9.0.x' - dotnet-quality: 'ga' - - name: Build - # disable deterministic builds, just for test run. Deterministic builds break coverage for some reason - run: dotnet build --configuration release /p:GITHUB_ACTIONS=false - name: Test id: tests continue-on-error: true # proceed if tests fail to allow for the report generation in master or next step failure in PR run: | - dotnet test \ - --configuration release \ - --framework ${{ matrix.framework }} \ - --no-build \ - --no-restore \ - --filter FullyQualifiedName\!~Dapr.E2E.Test \ - --logger "trx;LogFilePrefix=${{ matrix.prefix }}" \ - --logger "GitHubActions;report-warnings=false" \ - --results-directory "${{ github.workspace }}/TestResults" \ - /p:CollectCoverage=true \ - /p:CoverletOutputFormat=opencover \ - /p:GITHUB_ACTIONS=false + dotnet test "${{ matrix.project }}" \ + --configuration release \ + --framework ${{ matrix.framework }} \ + --logger "trx;LogFilePrefix=${{ matrix.prefix }}" \ + --logger "GitHubActions;report-warnings=false" \ + --results-directory "${{ github.workspace }}/TestResults" \ + /p:CollectCoverage=true \ + /p:CoverletOutputFormat=opencover \ + /p:GITHUB_ACTIONS=false - name: Check test failure in PR if: github.event_name == 'pull_request' && steps.tests.outcome != 'success' run: exit 1 @@ -120,7 +178,7 @@ jobs: uses: ./.github/workflows/itests.yml discover: name: 'Discover Packages' - needs: ['build', 'test', 'integration-test'] + needs: ['build', 'unit-tests', 'integration-test', 'integration-tests-v2'] runs-on: ubuntu-latest if: | startswith(github.ref, 'refs/tags/v') && diff --git a/all.sln b/all.sln index 0da90b211..d21d5083a 100644 --- a/all.sln +++ b/all.sln @@ -95,8 +95,6 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PublishEventExample", "exam EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BulkPublishEventExample", "examples\Client\PublishSubscribe\BulkPublishEventExample\BulkPublishEventExample.csproj", "{DDC41278-FB60-403A-B969-2AEBD7C2D83C}" EndProject -Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WorkflowUnitTest", "examples\Workflow\WorkflowUnitTest\WorkflowUnitTest.csproj", "{8CA09061-2BEF-4506-A763-07062D2BD6AC}" -EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "GeneratedActor", "GeneratedActor", "{7592AFA4-426B-42F3-AE82-957C86814482}" EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ActorClient", "examples\GeneratedActor\ActorClient\ActorClient.csproj", "{61C24126-F39D-4BEA-96DC-FC87BA730554}" @@ -205,13 +203,25 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "DistributedLock", "Distribu EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DistributedLock", "examples\DistributedLock\DistributedLock\DistributedLock.csproj", "{F2DFB0FE-DF35-4D94-9CC9-43212B1D6F93}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.TestContainers", "src\Dapr.TestContainers\Dapr.TestContainers.csproj", "{D14893E1-EF21-4EB4-9DAD-82B5127832CB}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Workflow", "test\Dapr.IntegrationTest.Workflow\Dapr.IntegrationTest.Workflow.csproj", "{FB21FAC7-09F7-4F68-910C-224EE7150B35}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.IntegrationTest.Jobs", "test\Dapr.IntegrationTest.Jobs\Dapr.IntegrationTest.Jobs.csproj", "{667E2F91-3004-4409-B6B8-9216EAFC44CF}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkflowUnitTest", "examples\Workflow\WorkflowUnitTest\WorkflowUnitTest.csproj", "{77176EC6-C586-47B1-BB72-533327F9E7BE}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WorkflowMapReduceDemo", "examples\Workflow\WorkflowMapReduceDemo\WorkflowMapReduceDemo.csproj", "{030CB614-6148-4863-A39A-1251728DE51D}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Workflow.Grpc", "src\Dapr.Workflow.Grpc\Dapr.Workflow.Grpc.csproj", "{5AECC3FC-7374-4534-A305-397E3290E573}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "integration", "integration", "{047680F1-C0FE-4DE9-A257-62FA8599C834}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "unit", "unit", "{0AF0FE8D-C234-4F04-8514-32206ACE01BD}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.E2E.Test.PubSub", "test\Dapr.E2E.Test.PubSub\Dapr.E2E.Test.PubSub.csproj", "{4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "new", "new", "{8462B106-175A-423A-BA94-BE0D39D0BD8E}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.E2E.Test.Workflow", "test\Dapr.E2E.Test.Workflow\Dapr.E2E.Test.Workflow.csproj", "{3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.Workflow.Abstractions", "src\Dapr.Workflow.Abstractions\Dapr.Workflow.Abstractions.csproj", "{CE5D4439-5B3C-4E97-B7E3-EB8610AEA3EF}" EndProject -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.E2E.Test.Jobs", "test\Dapr.E2E.Test.Jobs\Dapr.E2E.Test.Jobs.csproj", "{775302E3-69CC-4FBD-98CC-0F889A5D4C63}" +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Dapr.TestContainers", "src\Dapr.TestContainers\Dapr.TestContainers.csproj", "{A05D1519-6A82-498F-B7C9-3D14E08D35CA}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -355,8 +365,6 @@ Global {DDC41278-FB60-403A-B969-2AEBD7C2D83C}.Debug|Any CPU.Build.0 = Debug|Any CPU {DDC41278-FB60-403A-B969-2AEBD7C2D83C}.Release|Any CPU.ActiveCfg = Release|Any CPU {DDC41278-FB60-403A-B969-2AEBD7C2D83C}.Release|Any CPU.Build.0 = Release|Any CPU - {8CA09061-2BEF-4506-A763-07062D2BD6AC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {8CA09061-2BEF-4506-A763-07062D2BD6AC}.Release|Any CPU.ActiveCfg = Release|Any CPU {61C24126-F39D-4BEA-96DC-FC87BA730554}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {61C24126-F39D-4BEA-96DC-FC87BA730554}.Debug|Any CPU.Build.0 = Debug|Any CPU {61C24126-F39D-4BEA-96DC-FC87BA730554}.Release|Any CPU.ActiveCfg = Release|Any CPU @@ -549,40 +557,43 @@ Global {F2DFB0FE-DF35-4D94-9CC9-43212B1D6F93}.Debug|Any CPU.Build.0 = Debug|Any CPU {F2DFB0FE-DF35-4D94-9CC9-43212B1D6F93}.Release|Any CPU.ActiveCfg = Release|Any CPU {F2DFB0FE-DF35-4D94-9CC9-43212B1D6F93}.Release|Any CPU.Build.0 = Release|Any CPU - {D14893E1-EF21-4EB4-9DAD-82B5127832CB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {D14893E1-EF21-4EB4-9DAD-82B5127832CB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {D14893E1-EF21-4EB4-9DAD-82B5127832CB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {D14893E1-EF21-4EB4-9DAD-82B5127832CB}.Release|Any CPU.Build.0 = Release|Any CPU - {4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E}.Debug|Any CPU.Build.0 = Debug|Any CPU - {4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E}.Release|Any CPU.ActiveCfg = Release|Any CPU - {4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E}.Release|Any CPU.Build.0 = Release|Any CPU - {3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB}.Debug|Any CPU.Build.0 = Debug|Any CPU - {3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB}.Release|Any CPU.ActiveCfg = Release|Any CPU - {3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB}.Release|Any CPU.Build.0 = Release|Any CPU - {775302E3-69CC-4FBD-98CC-0F889A5D4C63}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {775302E3-69CC-4FBD-98CC-0F889A5D4C63}.Debug|Any CPU.Build.0 = Debug|Any CPU - {775302E3-69CC-4FBD-98CC-0F889A5D4C63}.Release|Any CPU.ActiveCfg = Release|Any CPU - {775302E3-69CC-4FBD-98CC-0F889A5D4C63}.Release|Any CPU.Build.0 = Release|Any CPU + {FB21FAC7-09F7-4F68-910C-224EE7150B35}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FB21FAC7-09F7-4F68-910C-224EE7150B35}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FB21FAC7-09F7-4F68-910C-224EE7150B35}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FB21FAC7-09F7-4F68-910C-224EE7150B35}.Release|Any CPU.Build.0 = Release|Any CPU + {667E2F91-3004-4409-B6B8-9216EAFC44CF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {667E2F91-3004-4409-B6B8-9216EAFC44CF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {667E2F91-3004-4409-B6B8-9216EAFC44CF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {667E2F91-3004-4409-B6B8-9216EAFC44CF}.Release|Any CPU.Build.0 = Release|Any CPU + {77176EC6-C586-47B1-BB72-533327F9E7BE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {77176EC6-C586-47B1-BB72-533327F9E7BE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {77176EC6-C586-47B1-BB72-533327F9E7BE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {77176EC6-C586-47B1-BB72-533327F9E7BE}.Release|Any CPU.Build.0 = Release|Any CPU + {030CB614-6148-4863-A39A-1251728DE51D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {030CB614-6148-4863-A39A-1251728DE51D}.Debug|Any CPU.Build.0 = Debug|Any CPU + {030CB614-6148-4863-A39A-1251728DE51D}.Release|Any CPU.ActiveCfg = Release|Any CPU + {030CB614-6148-4863-A39A-1251728DE51D}.Release|Any CPU.Build.0 = Release|Any CPU + {5AECC3FC-7374-4534-A305-397E3290E573}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5AECC3FC-7374-4534-A305-397E3290E573}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5AECC3FC-7374-4534-A305-397E3290E573}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5AECC3FC-7374-4534-A305-397E3290E573}.Release|Any CPU.Build.0 = Release|Any CPU + {CE5D4439-5B3C-4E97-B7E3-EB8610AEA3EF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {CE5D4439-5B3C-4E97-B7E3-EB8610AEA3EF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {CE5D4439-5B3C-4E97-B7E3-EB8610AEA3EF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {CE5D4439-5B3C-4E97-B7E3-EB8610AEA3EF}.Release|Any CPU.Build.0 = Release|Any CPU + {A05D1519-6A82-498F-B7C9-3D14E08D35CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A05D1519-6A82-498F-B7C9-3D14E08D35CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A05D1519-6A82-498F-B7C9-3D14E08D35CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A05D1519-6A82-498F-B7C9-3D14E08D35CA}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection GlobalSection(NestedProjects) = preSolution {C2DB4B64-B7C3-4FED-8753-C040F677C69A} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {41BF4392-54BD-4FE7-A3EB-CD045F88CA9A} = {DD020B34-460F-455F-8D17-CF4A949F100B} {B9C12532-0969-4DAC-A2F8-CA9208D7A901} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {62E41317-ED5D-4AA4-B129-C9E56C27354C} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {08D602F6-7C11-4653-B70B-B56333BF6FD2} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {383609C1-F43F-49EB-85E4-1964EE7F0F14} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {B314AD5E-10AC-418A-B021-D4206BF37ACF} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {0CD1912D-5E27-4A2A-A998-164792E0D006} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {342783B5-F75B-4752-A3E2-B8CB7D09C080} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {9C1D6ABA-5EDE-4FA0-A8A9-0AB98CB74737} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {95BAF30B-8089-42CE-8530-6DFBCE1F6A07} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {1BA7E772-8AA7-4D5A-800D-66B17F62421C} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {78FC19B2-396C-4ED2-BFD9-6C5667C61666} = {DD020B34-460F-455F-8D17-CF4A949F100B} {B615B353-476C-43B9-A776-B193B0DBD256} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {A11DC259-D1DB-4686-AD28-A427D0BABA83} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {2EC50C79-782D-4985-ABB1-AD07F35D1621} = {A11DC259-D1DB-4686-AD28-A427D0BABA83} @@ -596,12 +607,7 @@ Global {A7F41094-8648-446B-AECD-DCC2CC871F73} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {F70AC78E-8925-4770-832A-2FC67A620EB2} = {A7F41094-8648-446B-AECD-DCC2CC871F73} {8B570E70-0E73-4042-A4B6-1CC3CC782A65} = {A7F41094-8648-446B-AECD-DCC2CC871F73} - {4AA9E7B7-36BF-4AAE-BFA3-C9CE8740F4A0} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {345FC3FB-D1E9-4AE8-9052-17D20AB01FA2} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {2AED1542-A8ED-488D-B6D0-E16AB5D6EF6C} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {E8212911-344B-4638-ADC3-B215BCDCAFD1} = {DD020B34-460F-455F-8D17-CF4A949F100B} {F80F837E-D2FC-4FFC-B68F-3CF0EC015F66} = {A7F41094-8648-446B-AECD-DCC2CC871F73} - {5BE7F505-7D77-4C3A-ABFD-54088774DAA7} = {DD020B34-460F-455F-8D17-CF4A949F100B} {35031EDB-C0DE-453A-8335-D2EBEA2FC640} = {A7F41094-8648-446B-AECD-DCC2CC871F73} {07578B6C-9B96-4B3D-BA2E-7800EFCA7F99} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} @@ -609,19 +615,14 @@ Global {0EF6EA64-D7C3-420D-9890-EAE8D54A57E6} = {A7F41094-8648-446B-AECD-DCC2CC871F73} {4A175C27-EAFE-47E7-90F6-873B37863656} = {0EF6EA64-D7C3-420D-9890-EAE8D54A57E6} {DDC41278-FB60-403A-B969-2AEBD7C2D83C} = {0EF6EA64-D7C3-420D-9890-EAE8D54A57E6} - {8CA09061-2BEF-4506-A763-07062D2BD6AC} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} {7592AFA4-426B-42F3-AE82-957C86814482} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {61C24126-F39D-4BEA-96DC-FC87BA730554} = {7592AFA4-426B-42F3-AE82-957C86814482} {CB903D21-4869-42EF-BDD6-5B1CFF674337} = {7592AFA4-426B-42F3-AE82-957C86814482} {980B5FD8-0107-41F7-8FAD-E4E8BAE8A625} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {7C06FE2D-6C62-48F5-A505-F0D715C554DE} = {7592AFA4-426B-42F3-AE82-957C86814482} - {AF89083D-4715-42E6-93E9-38497D12A8A6} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {B5CDB0DC-B26D-48F1-B934-FE5C1C991940} = {DD020B34-460F-455F-8D17-CF4A949F100B} {DFBABB04-50E9-42F6-B470-310E1B545638} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {B445B19C-A925-4873-8CB7-8317898B6970} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {CDB47863-BEBD-4841-A807-46D868962521} = {DD020B34-460F-455F-8D17-CF4A949F100B} {273F2527-1658-4CCF-8DC6-600E921188C5} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {2F3700EF-1CDA-4C15-AC88-360230000ECD} = {DD020B34-460F-455F-8D17-CF4A949F100B} {3046DBF4-C2FF-4F3A-9176-E1C01E0A90E5} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {11011FF8-77EA-4B25-96C0-29D4D486EF1C} = {3046DBF4-C2FF-4F3A-9176-E1C01E0A90E5} {43CB06A9-7E88-4C5F-BFB8-947E072CBC9F} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} @@ -630,22 +631,14 @@ Global {FD3E9371-3134-4235-8E80-32226DFB4B1F} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} {D83B27F3-4401-42F5-843E-147566B4999A} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} {00359961-0C50-4BB1-A794-8B06DE991639} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} - {4E04EB35-7FD2-4FDB-B09A-F75CE24053B9} = {DD020B34-460F-455F-8D17-CF4A949F100B} {0EAE36A1-B578-4F13-A113-7A477ECA1BDA} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {C8BB6A85-A7EA-40C0-893D-F36F317829B3} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {BF9828E9-5597-4D42-AA6E-6E6C12214204} = {DD020B34-460F-455F-8D17-CF4A949F100B} {D9697361-232F-465D-A136-4561E0E88488} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {9CAF360E-5AD3-4C4F-89A0-327EEB70D673} = {D9697361-232F-465D-A136-4561E0E88488} - {E90114C6-86FC-43B8-AE5C-D9273CF21FE4} = {DD020B34-460F-455F-8D17-CF4A949F100B} {55A7D436-CC8C-47E6-B43A-DFE32E0FE38C} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {CE0D5FEB-F6DB-4EB8-B8A9-6A4A32944539} = {DD020B34-460F-455F-8D17-CF4A949F100B} {28B87C37-4B52-400F-B84D-64F134931BDC} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {CADEAE45-8981-4723-B641-9C28251C7D3B} = {DD020B34-460F-455F-8D17-CF4A949F100B} {E49C822C-E921-48DF-897B-3E603CA596D2} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {A2C0F203-11FF-4B7F-A94F-B9FD873573FE} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {7E23E229-6823-4D84-AF3A-AE14CEAEF52A} = {DD020B34-460F-455F-8D17-CF4A949F100B} {160EFFA0-F6B9-49E4-B62B-68C0D53DB425} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {B508EBD6-0F14-480C-A446-45A09052733B} = {DD020B34-460F-455F-8D17-CF4A949F100B} {6843B5B3-9E95-4022-B792-8A1DE6BFEFEC} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {097D5F6F-D26F-4BFB-9074-FA52577EB442} = {6843B5B3-9E95-4022-B792-8A1DE6BFEFEC} {442E80E5-8040-4123-B88A-26FD36BA95D9} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} @@ -664,10 +657,42 @@ Global {9BD12D26-AD9B-4C76-A97F-7A89B7276ABE} = {27C5D71D-0721-4221-9286-B94AB07B58CF} {11D2CA0F-6D38-4DC7-AE06-C1DAE7FC1C20} = {D687DDC4-66C5-4667-9E3A-FD8B78ECAA78} {F2DFB0FE-DF35-4D94-9CC9-43212B1D6F93} = {11D2CA0F-6D38-4DC7-AE06-C1DAE7FC1C20} - {D14893E1-EF21-4EB4-9DAD-82B5127832CB} = {27C5D71D-0721-4221-9286-B94AB07B58CF} - {4997B2D1-49AF-40B7-8D84-4BD1E94D9B2E} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {3CE069E8-9AAB-4DE4-90AC-077C1DB4EAEB} = {DD020B34-460F-455F-8D17-CF4A949F100B} - {775302E3-69CC-4FBD-98CC-0F889A5D4C63} = {DD020B34-460F-455F-8D17-CF4A949F100B} + {77176EC6-C586-47B1-BB72-533327F9E7BE} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} + {030CB614-6148-4863-A39A-1251728DE51D} = {BF3ED6BF-ADF3-4D25-8E89-02FB8D945CA9} + {5AECC3FC-7374-4534-A305-397E3290E573} = {27C5D71D-0721-4221-9286-B94AB07B58CF} + {047680F1-C0FE-4DE9-A257-62FA8599C834} = {DD020B34-460F-455F-8D17-CF4A949F100B} + {B5CDB0DC-B26D-48F1-B934-FE5C1C991940} = {047680F1-C0FE-4DE9-A257-62FA8599C834} + {345FC3FB-D1E9-4AE8-9052-17D20AB01FA2} = {047680F1-C0FE-4DE9-A257-62FA8599C834} + {342783B5-F75B-4752-A3E2-B8CB7D09C080} = {047680F1-C0FE-4DE9-A257-62FA8599C834} + {1BA7E772-8AA7-4D5A-800D-66B17F62421C} = {047680F1-C0FE-4DE9-A257-62FA8599C834} + {2AED1542-A8ED-488D-B6D0-E16AB5D6EF6C} = {047680F1-C0FE-4DE9-A257-62FA8599C834} + {4AA9E7B7-36BF-4AAE-BFA3-C9CE8740F4A0} = {047680F1-C0FE-4DE9-A257-62FA8599C834} + {95BAF30B-8089-42CE-8530-6DFBCE1F6A07} = {047680F1-C0FE-4DE9-A257-62FA8599C834} + {5BE7F505-7D77-4C3A-ABFD-54088774DAA7} = {047680F1-C0FE-4DE9-A257-62FA8599C834} + {0CD1912D-5E27-4A2A-A998-164792E0D006} = {047680F1-C0FE-4DE9-A257-62FA8599C834} + {E8212911-344B-4638-ADC3-B215BCDCAFD1} = {047680F1-C0FE-4DE9-A257-62FA8599C834} + {0AF0FE8D-C234-4F04-8514-32206ACE01BD} = {DD020B34-460F-455F-8D17-CF4A949F100B} + {A2C0F203-11FF-4B7F-A94F-B9FD873573FE} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {9C1D6ABA-5EDE-4FA0-A8A9-0AB98CB74737} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {AF89083D-4715-42E6-93E9-38497D12A8A6} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {41BF4392-54BD-4FE7-A3EB-CD045F88CA9A} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {2F3700EF-1CDA-4C15-AC88-360230000ECD} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {7E23E229-6823-4D84-AF3A-AE14CEAEF52A} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {B314AD5E-10AC-418A-B021-D4206BF37ACF} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {383609C1-F43F-49EB-85E4-1964EE7F0F14} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {CDB47863-BEBD-4841-A807-46D868962521} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {B508EBD6-0F14-480C-A446-45A09052733B} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {78FC19B2-396C-4ED2-BFD9-6C5667C61666} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {CADEAE45-8981-4723-B641-9C28251C7D3B} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {BF9828E9-5597-4D42-AA6E-6E6C12214204} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {4E04EB35-7FD2-4FDB-B09A-F75CE24053B9} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {CE0D5FEB-F6DB-4EB8-B8A9-6A4A32944539} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {E90114C6-86FC-43B8-AE5C-D9273CF21FE4} = {0AF0FE8D-C234-4F04-8514-32206ACE01BD} + {8462B106-175A-423A-BA94-BE0D39D0BD8E} = {047680F1-C0FE-4DE9-A257-62FA8599C834} + {667E2F91-3004-4409-B6B8-9216EAFC44CF} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} + {FB21FAC7-09F7-4F68-910C-224EE7150B35} = {8462B106-175A-423A-BA94-BE0D39D0BD8E} + {CE5D4439-5B3C-4E97-B7E3-EB8610AEA3EF} = {27C5D71D-0721-4221-9286-B94AB07B58CF} + {A05D1519-6A82-498F-B7C9-3D14E08D35CA} = {27C5D71D-0721-4221-9286-B94AB07B58CF} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {65220BF2-EAE1-4CB2-AA58-EBE80768CB40} diff --git a/examples/Hosting/Aspire/ServiceInvocationDemo/ServiceInvocationDemo.ServiceDefaults/Extensions.cs b/examples/Hosting/Aspire/ServiceInvocationDemo/ServiceInvocationDemo.ServiceDefaults/Extensions.cs index fd3fb27de..b28d57de1 100644 --- a/examples/Hosting/Aspire/ServiceInvocationDemo/ServiceInvocationDemo.ServiceDefaults/Extensions.cs +++ b/examples/Hosting/Aspire/ServiceInvocationDemo/ServiceInvocationDemo.ServiceDefaults/Extensions.cs @@ -29,7 +29,7 @@ public static TBuilder AddServiceDefaults(this TBuilder builder) where builder.Services.ConfigureHttpClientDefaults(http => { // Turn on resilience by default - http.AddStandardResilienceHandler(); + //http.AddStandardResilienceHandler(); // Turn on service discovery by default http.AddServiceDiscovery(); diff --git a/examples/Workflow/WorkflowAsyncOperations/Program.cs b/examples/Workflow/WorkflowAsyncOperations/Program.cs index df46d1f9b..3202690db 100644 --- a/examples/Workflow/WorkflowAsyncOperations/Program.cs +++ b/examples/Workflow/WorkflowAsyncOperations/Program.cs @@ -41,8 +41,9 @@ var status = await daprWorkflowClient.GetWorkflowStateAsync(instanceId); do { - Console.WriteLine($"Current status: {status.RuntimeStatus}, step: {status.ReadCustomStatusAs()}"); + Console.WriteLine($"{DateTime.UtcNow:HH:mm:ss zz}: Current status: {status?.RuntimeStatus}, step: {status?.ReadCustomStatusAs()}"); status = await daprWorkflowClient.GetWorkflowStateAsync(instanceId); -} while (!status.IsWorkflowCompleted); + await Task.Delay(TimeSpan.FromSeconds(1)); +} while (status?.IsWorkflowCompleted == false); -Console.WriteLine($"Workflow completed - {status.ReadCustomStatusAs()}"); +Console.WriteLine($"Workflow completed - {status?.ReadCustomStatusAs()}"); diff --git a/examples/Workflow/WorkflowConsoleApp/Program.cs b/examples/Workflow/WorkflowConsoleApp/Program.cs index 26d34615d..5ed5713df 100644 --- a/examples/Workflow/WorkflowConsoleApp/Program.cs +++ b/examples/Workflow/WorkflowConsoleApp/Program.cs @@ -11,6 +11,7 @@ // The workflow host is a background service that connects to the sidecar over gRPC var builder = Host.CreateDefaultBuilder(args).ConfigureServices(services => { + services.AddDaprClient(); services.AddDaprWorkflow(options => { // Note that it's also possible to register a lambda function as the workflow @@ -27,10 +28,10 @@ }); // Dapr uses a random port for gRPC by default. If we don't know what that port -// is (because this app was started separate from dapr), then assume 4001. +// is (because this app was started separate from dapr), then assume 50001 if (string.IsNullOrEmpty(Environment.GetEnvironmentVariable("DAPR_GRPC_PORT"))) { - Environment.SetEnvironmentVariable("DAPR_GRPC_PORT", "4001"); + Environment.SetEnvironmentVariable("DAPR_GRPC_PORT", "50001"); } Console.ForegroundColor = ConsoleColor.White; @@ -38,7 +39,7 @@ Console.WriteLine("*** Using this app, you can place orders that start workflows."); Console.WriteLine("*** Ensure that Dapr is running in a separate terminal window using the following command:"); Console.ForegroundColor = ConsoleColor.Green; -Console.WriteLine(" dapr run --dapr-grpc-port 4001 --app-id wfapp"); +Console.WriteLine(" dapr run --dapr-grpc-port 50001 --app-id wfapp"); Console.WriteLine(); Console.ResetColor(); @@ -215,6 +216,9 @@ await daprWorkflowClient.RaiseEventAsync( Console.WriteLine(); } } + +return; + static async Task RestockInventory(DaprClient daprClient, List inventory) { Console.WriteLine("*** Restocking inventory..."); diff --git a/examples/Workflow/WorkflowConsoleApp/Properties/launchSettings.json b/examples/Workflow/WorkflowConsoleApp/Properties/launchSettings.json index daf820a44..5e7da110f 100644 --- a/examples/Workflow/WorkflowConsoleApp/Properties/launchSettings.json +++ b/examples/Workflow/WorkflowConsoleApp/Properties/launchSettings.json @@ -2,7 +2,7 @@ "profiles": { "OrderingWebApi": { "commandName": "Project", - "launchBrowser": true, + "launchBrowser": false, "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" }, diff --git a/examples/Workflow/WorkflowConsoleApp/WorkflowConsoleApp.csproj b/examples/Workflow/WorkflowConsoleApp/WorkflowConsoleApp.csproj index 9b38483c5..3842e2472 100644 --- a/examples/Workflow/WorkflowConsoleApp/WorkflowConsoleApp.csproj +++ b/examples/Workflow/WorkflowConsoleApp/WorkflowConsoleApp.csproj @@ -1,6 +1,8 @@  + + diff --git a/examples/Workflow/WorkflowConsoleApp/Workflows/OrderProcessingWorkflow.cs b/examples/Workflow/WorkflowConsoleApp/Workflows/OrderProcessingWorkflow.cs index 988c9693a..fb71b8661 100644 --- a/examples/Workflow/WorkflowConsoleApp/Workflows/OrderProcessingWorkflow.cs +++ b/examples/Workflow/WorkflowConsoleApp/Workflows/OrderProcessingWorkflow.cs @@ -111,7 +111,7 @@ await context.CallActivityAsync( } // Let them know their payment was processed - logger.LogError("Order {orderId} has completed!", orderId); + logger.LogInformation("Order {orderId} has completed!", orderId); await context.CallActivityAsync( nameof(NotifyActivity), new Notification($"Order {orderId} has completed!")); @@ -119,4 +119,4 @@ await context.CallActivityAsync( // End the workflow with a success result return new OrderResult(Processed: true); } -} \ No newline at end of file +} diff --git a/examples/Workflow/WorkflowExternalInteraction/Program.cs b/examples/Workflow/WorkflowExternalInteraction/Program.cs index b83527d25..201ef78cd 100644 --- a/examples/Workflow/WorkflowExternalInteraction/Program.cs +++ b/examples/Workflow/WorkflowExternalInteraction/Program.cs @@ -37,33 +37,10 @@ await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(DemoWorkflow), instanceId, instanceId); - -bool enterPressed = false; Console.WriteLine("Press [ENTER] within the next 10 seconds to approve this workflow"); -using (var cts = new CancellationTokenSource()) -{ - var inputTask = Task.Run(() => - { - if (Console.ReadKey().Key == ConsoleKey.Enter) - { - Console.WriteLine("Approved"); - enterPressed = true; - cts.Cancel(); //Cancel the delay task if Enter is pressed - } - }); - - try - { - await Task.Delay(TimeSpan.FromSeconds(10), cts.Token); - } - catch (TaskCanceledException) - { - // Task was cancelled because Enter was pressed - } -} - -if (enterPressed) +if (await WaitForEnterAsync(TimeSpan.FromSeconds(10))) { + Console.WriteLine("Approved"); await daprWorkflowClient.RaiseEventAsync(instanceId, "Approval", true); } else @@ -73,4 +50,28 @@ await daprWorkflowClient.WaitForWorkflowCompletionAsync(instanceId); var state = await daprWorkflowClient.GetWorkflowStateAsync(instanceId); -Console.WriteLine($"Workflow state: {state.RuntimeStatus}"); +Console.WriteLine($"Workflow state: {state?.RuntimeStatus}"); +return; + +static async Task WaitForEnterAsync(TimeSpan timeout, CancellationToken cancellationToken = default) +{ + var deadline = DateTime.UtcNow + timeout; + using var timer = new PeriodicTimer(TimeSpan.FromMilliseconds(50)); + while (DateTime.UtcNow < deadline) + { + cancellationToken.ThrowIfCancellationRequested(); + ; + + while (Console.KeyAvailable) // Drain buffered keys + { + var key = Console.ReadKey(intercept: true); + if (key.Key == ConsoleKey.Enter) + return true; + } + + // Wait a bit before checking against + await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false); + } + + return false; +} diff --git a/examples/Workflow/WorkflowExternalInteraction/Workflows/DemoWorkflow.cs b/examples/Workflow/WorkflowExternalInteraction/Workflows/DemoWorkflow.cs index 1d13b894f..98ed5b578 100644 --- a/examples/Workflow/WorkflowExternalInteraction/Workflows/DemoWorkflow.cs +++ b/examples/Workflow/WorkflowExternalInteraction/Workflows/DemoWorkflow.cs @@ -28,8 +28,7 @@ public override async Task RunAsync(WorkflowContext context, string input) { try { - await context.WaitForExternalEventAsync("Approval"); - //await context.WaitForExternalEventAsync(eventName: "Approval", timeout: TimeSpan.FromSeconds(10)); + await context.WaitForExternalEventAsync("Approval", timeout: TimeSpan.FromSeconds(10)); } catch (TaskCanceledException) { diff --git a/examples/Workflow/WorkflowFanOutFanIn/Program.cs b/examples/Workflow/WorkflowFanOutFanIn/Program.cs index 16d018ab0..fdedef144 100644 --- a/examples/Workflow/WorkflowFanOutFanIn/Program.cs +++ b/examples/Workflow/WorkflowFanOutFanIn/Program.cs @@ -37,4 +37,4 @@ await daprWorkflowClient.WaitForWorkflowCompletionAsync(instanceId); var state = await daprWorkflowClient.GetWorkflowStateAsync(instanceId); -Console.WriteLine($"Workflow state: {state.RuntimeStatus}"); +Console.WriteLine($"Workflow state: {state?.RuntimeStatus}"); diff --git a/examples/Workflow/WorkflowMapReduceDemo/Program.cs b/examples/Workflow/WorkflowMapReduceDemo/Program.cs new file mode 100644 index 000000000..54181ff87 --- /dev/null +++ b/examples/Workflow/WorkflowMapReduceDemo/Program.cs @@ -0,0 +1,57 @@ + +using System.Diagnostics; +using Dapr.Workflow; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using WorkflowMapReduceDemo.Workflows; +using WorkflowMapReduceDemo.Workflows.Activities; + +using var app = Host.CreateDefaultBuilder() + .ConfigureLogging(b => b.AddConsole()) + .ConfigureServices(services => + { + services.AddDaprWorkflow(opt => + { + opt.RegisterWorkflow(); + opt.RegisterWorkflow(); + opt.RegisterActivity(); + }); + }) + .Build(); + +await app.StartAsync(); + +await using var scope = app.Services.CreateAsyncScope(); +var logger = scope.ServiceProvider.GetRequiredService>(); +var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); +var input = new MapReduceInput( + ShardCount: 200, + WorkersPerShard: 15, + WorkerDelayMsBase: 100, + WorkerDelayMsJitter: 250, + ShardBatchSize: 20, + WorkerBatchSize: 10); + +var instanceId = Guid.NewGuid().ToString(); +logger.LogInformation("Starting workflow with instance ID '{instanceId}'", instanceId); + +var sw = Stopwatch.StartNew(); +await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(MapReduceWorkflow), instanceId, input); +var result = await daprWorkflowClient.WaitForWorkflowCompletionAsync(instanceId, true); +sw.Stop(); +Console.WriteLine(result); + +var subworkflowCount = input.ShardCount; +var totalOperations = (long)input.ShardCount * input.WorkersPerShard; +var workflowOutput = result.ReadOutputAs(); +var totalActivityDelayMs = workflowOutput?.IntentionalDelayMs ?? null; + +logger.LogInformation( + "Workflow run summary: instanceId={InstanceId}, workflow={WorkflowName}, elapsedMs={ElapsedMs}, totalActivityDelayTimeMs={TotalDelayTimeMs} shards(subworkflows)={SubworkflowCount}, totalOperations={TotalOperations}", + instanceId, + nameof(MapReduceWorkflow), + sw.ElapsedMilliseconds, + totalActivityDelayMs, + subworkflowCount, + totalOperations); diff --git a/examples/Workflow/WorkflowMapReduceDemo/WorkflowMapReduceDemo.csproj b/examples/Workflow/WorkflowMapReduceDemo/WorkflowMapReduceDemo.csproj new file mode 100644 index 000000000..e9d52d1c4 --- /dev/null +++ b/examples/Workflow/WorkflowMapReduceDemo/WorkflowMapReduceDemo.csproj @@ -0,0 +1,13 @@ + + + + Exe + enable + enable + + + + + + + diff --git a/examples/Workflow/WorkflowMapReduceDemo/Workflows/Activities/MapWorkerActivity.cs b/examples/Workflow/WorkflowMapReduceDemo/Workflows/Activities/MapWorkerActivity.cs new file mode 100644 index 000000000..e21780199 --- /dev/null +++ b/examples/Workflow/WorkflowMapReduceDemo/Workflows/Activities/MapWorkerActivity.cs @@ -0,0 +1,86 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Workflow; +using Microsoft.Extensions.Logging; + +namespace WorkflowMapReduceDemo.Workflows.Activities; + +/// +/// Synthetic worker activity: deterministic "work" + optional small delay. +/// No external dependencies. +/// +public sealed partial class MapWorkerActivity(ILogger logger) : WorkflowActivity +{ + public override async Task RunAsync(WorkflowActivityContext context, MapWorkerInput input) + { + // Small delay to simulate work and create scheduling pressure. + // Deterministic jitter derived from inputs (no randomness). + LogStart(input); + var jitter = 0; + if (input.DelayMsJitter > 0) + { + var range = (uint)input.DelayMsJitter + 1u; + var hash = HashToUInt32(input.Seed, input.ShardId, input.WorkerId); + jitter = (int)(hash % range); + } + + var delay = Math.Max(0, input.DelayMsBase) + jitter; + delay = Math.Min(delay, 5_000); + + if (delay > 0) + { + await Task.Delay(delay); + } + + long result = + (input.Seed & 0xFFFF) + + (input.ShardId * 1_000L) + + input.WorkerId; + + LogComplete(result); + return new MapWorkerOutput(result, delay); + } + + private static uint HashToUInt32(int seed, int shardId, int workerId) + { + unchecked + { + // Simple, deterministic, low-risk hash (no Math.Abs, no big multiplies). + uint x = (uint)seed; + x ^= (uint)shardId * 0x9E3779B9u; + x ^= (uint)workerId * 0x85EBCA6Bu; + + // A couple of xorshift steps for mixing. + x ^= x << 13; + x ^= x >> 17; + x ^= x << 5; + return x; + } + } + + [LoggerMessage(LogLevel.Information, "Activity starting: {Input}")] + partial void LogStart(MapWorkerInput input); + [LoggerMessage(LogLevel.Information, "Activity finished with result '{Result}'")] + partial void LogComplete(long result); +} + +public sealed record MapWorkerOutput(long Result, long DelayMs); + +public sealed record MapWorkerInput( + int ShardId, + int WorkerId, + int Seed, + int DelayMsBase, + int DelayMsJitter); + diff --git a/examples/Workflow/WorkflowMapReduceDemo/Workflows/MapReduceWorkflow.cs b/examples/Workflow/WorkflowMapReduceDemo/Workflows/MapReduceWorkflow.cs new file mode 100644 index 000000000..0e0bb6375 --- /dev/null +++ b/examples/Workflow/WorkflowMapReduceDemo/Workflows/MapReduceWorkflow.cs @@ -0,0 +1,67 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Workflow; +using Microsoft.Extensions.Logging; + +namespace WorkflowMapReduceDemo.Workflows; + +public sealed partial class MapReduceWorkflow : Workflow +{ + public override async Task RunAsync(WorkflowContext context, MapReduceInput input) + { + var logger = context.CreateReplaySafeLogger(); + + input = input with + { + ShardCount = input.ShardCount <= 0 ? 1 : input.ShardCount, + WorkersPerShard = input.WorkersPerShard <= 0 ? 1 : input.WorkersPerShard, + ShardBatchSize = input.ShardBatchSize <= 0 ? input.ShardCount : input.ShardBatchSize + }; + + var status1 = new { Phase = "Starting", input.ShardCount, input.WorkersPerShard, input.ShardBatchSize }; + LogSettingStatus(logger, status1); + context.SetCustomStatus(status1); + + var shardIds = Enumerable.Range(0, input.ShardCount); + + var status2 = new { Phase = "RunningShards", input.ShardCount, MaxParallelShards = input.ShardBatchSize }; + LogSettingStatus(logger, status2); + context.SetCustomStatus(status2); + + var shardSums = await context.ProcessInParallelAsync(shardIds, shardId => + { + var shardInput = new ShardWorkflowInput(shardId, input.WorkersPerShard, input.Seed, input.WorkerDelayMsBase, + input.WorkerDelayMsJitter, input.WorkerBatchSize); + + return context.CallChildWorkflowAsync(nameof(ShardWorkflow), shardInput); + }, input.ShardBatchSize); + long total = shardSums.Sum(a => a.Result); + var intentionalDelayMs = shardSums.Sum(a => a.ActivityDelayMs); + + var result = new MapReduceResult(total, input.ShardCount, input.WorkersPerShard, intentionalDelayMs); + + var status3 = new { Phase = "Completed", result.ShardCount, result.WorkersPerShard, result.Total }; + LogSettingStatus(logger, status3); + context.SetCustomStatus(status3); + + return result; + } + + [LoggerMessage(LogLevel.Information, "Setting custom status: {Status}")] + static partial void LogSettingStatus(ILogger logger, object status); +} + +public sealed record MapReduceInput(int ShardCount, int WorkersPerShard, int Seed = 12345, int WorkerDelayMsBase = 2, int WorkerDelayMsJitter = 5, int ShardBatchSize = 25, int WorkerBatchSize = 100); +public sealed record MapReduceResult(long Total, int ShardCount, int WorkersPerShard, long IntentionalDelayMs); +public sealed record ShardWorkflowInput(int ShardId, int WorkersPerShard, int Seed, int WorkerDelayMsBase, int WorkerDelayMsJitter, int WorkerBatchSize); diff --git a/examples/Workflow/WorkflowMapReduceDemo/Workflows/ShardWorkflow.cs b/examples/Workflow/WorkflowMapReduceDemo/Workflows/ShardWorkflow.cs new file mode 100644 index 000000000..b92329f80 --- /dev/null +++ b/examples/Workflow/WorkflowMapReduceDemo/Workflows/ShardWorkflow.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Workflow; +using WorkflowMapReduceDemo.Workflows.Activities; + +namespace WorkflowMapReduceDemo.Workflows; + +public sealed class ShardWorkflow : Workflow +{ + public override async Task RunAsync(WorkflowContext context, ShardWorkflowInput input) + { + var workers = input.WorkersPerShard <= 0 ? 1 : input.WorkersPerShard; + var maxParallelWorkers = input.WorkerBatchSize <= 0 ? workers : input.WorkerBatchSize; + var workerIds = Enumerable.Range(0, workers); + + var workerOutputs = await context.ProcessInParallelAsync( + workerIds, + workerId => + context.CallActivityAsync( + nameof(MapWorkerActivity), + new MapWorkerInput( + ShardId: input.ShardId, + WorkerId: workerId, + Seed: input.Seed, + DelayMsBase: input.WorkerDelayMsBase, + DelayMsJitter: input.WorkerDelayMsJitter)), + maxParallelWorkers); + + var shardSum = workerOutputs.Sum(a => a.Result); + var totalIntentionalDelayMs = workerOutputs.Sum(a => a.DelayMs); + + context.SetCustomStatus(new + { + Phase = "ShardCompleted", + input.ShardId, + Workers = workers, + ShardSum = shardSum, + IntentionalDelayMs = totalIntentionalDelayMs + }); + + return new ShardWorkflowOutput(shardSum, totalIntentionalDelayMs); + } +} + +public sealed record ShardWorkflowOutput(long Result, long ActivityDelayMs); diff --git a/examples/Workflow/WorkflowMonitor/Program.cs b/examples/Workflow/WorkflowMonitor/Program.cs index 05697d480..7bc916781 100644 --- a/examples/Workflow/WorkflowMonitor/Program.cs +++ b/examples/Workflow/WorkflowMonitor/Program.cs @@ -33,7 +33,7 @@ var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); var instanceId = $"demo-workflow-{Guid.NewGuid().ToString()[..8]}"; -var isHealthy = true; +const bool isHealthy = true; await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(DemoWorkflow), instanceId, isHealthy); //We don't want to block on workflow completion as this workflow will never complete diff --git a/examples/Workflow/WorkflowParallelFanOut/OrderProcessingWorkflow.cs b/examples/Workflow/WorkflowParallelFanOut/OrderProcessingWorkflow.cs index 6efd01d99..3806a5416 100644 --- a/examples/Workflow/WorkflowParallelFanOut/OrderProcessingWorkflow.cs +++ b/examples/Workflow/WorkflowParallelFanOut/OrderProcessingWorkflow.cs @@ -28,10 +28,7 @@ public override async Task RunAsync(WorkflowContext context, Orde { var logger = context.CreateReplaySafeLogger(); - if (!context.IsReplaying) - { - LogStartingOrderProcessorWorkflow(logger, orders.Length); - } + LogStartingOrderProcessorWorkflow(logger, orders.Length); //Process all orders in parallel with controlled concurrency var orderResults = await context.ProcessInParallelAsync( @@ -42,12 +39,9 @@ public override async Task RunAsync(WorkflowContext context, Orde var totalProcessed = orderResults.Count(r => r.IsProcessed); var totalFailed = orderResults.Length - totalProcessed; var totalAmount = orderResults.Where(r => r.IsProcessed).Sum(r => r.TotalAmount); - - if (!context.IsReplaying) - { - LogCompletedProcessingWorkflow(logger, orders.Length, totalProcessed, totalFailed, totalAmount); - } - + + LogCompletedProcessingWorkflow(logger, orders.Length, totalProcessed, totalFailed, totalAmount); + return orderResults; } diff --git a/examples/Workflow/WorkflowParallelFanOut/ProcessOrderActivity.cs b/examples/Workflow/WorkflowParallelFanOut/ProcessOrderActivity.cs index ba0cb7714..a75daa2ed 100644 --- a/examples/Workflow/WorkflowParallelFanOut/ProcessOrderActivity.cs +++ b/examples/Workflow/WorkflowParallelFanOut/ProcessOrderActivity.cs @@ -13,7 +13,6 @@ using Dapr.Workflow; using Microsoft.Extensions.Logging; -using ILogger = Microsoft.Extensions.Logging.ILogger; namespace WorkflowParallelFanOut; @@ -29,7 +28,7 @@ public sealed partial class ProcessOrderActivity(ILogger l /// The output of the activity as a task. public override async Task RunAsync(WorkflowActivityContext context, OrderRequest order) { - LogProcessingOrder(logger, order.OrderId, order.ProductName); + LogProcessingOrder(order.OrderId, order.ProductName); //Simulate processing time (between 100 and 2000ms) var processingTime = Random.Next(100, 2000); @@ -40,12 +39,12 @@ public override async Task RunAsync(WorkflowActivityContext context if (shouldFail) { - LogOrderFailed(logger, order.OrderId); + LogOrderFailed(order.OrderId); return new OrderResult( order.OrderId, IsProcessed: false, TotalAmount: 0, - Status: "Failed - System Error", + Status: "Failed - Simulated Transient Error", ProcessedAt: DateTime.UtcNow); } @@ -53,7 +52,7 @@ public override async Task RunAsync(WorkflowActivityContext context var hasInventory = Random.Next(1, 101) <= 90; // 90% chance of having inventory if (!hasInventory) { - LogInsufficientInventory(logger, order.OrderId); + LogInsufficientInventory(order.OrderId); return new OrderResult( order.OrderId, IsProcessed: false, @@ -69,10 +68,10 @@ public override async Task RunAsync(WorkflowActivityContext context if (order.Quantity >= 10) { totalAmount *= 0.9m; // 10% discount - LogDiscountApplied(logger, order.OrderId); + LogDiscountApplied(order.OrderId); } - LogSuccessfullyProcessedOrder(logger, order.OrderId, totalAmount); + LogSuccessfullyProcessedOrder(order.OrderId, totalAmount); return new OrderResult( order.OrderId, IsProcessed: true, @@ -82,17 +81,17 @@ public override async Task RunAsync(WorkflowActivityContext context } [LoggerMessage(LogLevel.Information, "Processing order {OrderId} for product {ProductName}")] - static partial void LogProcessingOrder(ILogger logger, string orderId, string productName); + partial void LogProcessingOrder(string orderId, string productName); [LoggerMessage(LogLevel.Warning, "Order {OrderId} failed during processing")] - static partial void LogOrderFailed(ILogger logger, string orderId); + partial void LogOrderFailed(string orderId); [LoggerMessage(LogLevel.Warning, "Order {OrderId} failed - insufficient inventory")] - static partial void LogInsufficientInventory(ILogger logger, string orderId); + partial void LogInsufficientInventory(string orderId); [LoggerMessage(LogLevel.Information, "Applied bulk discount to order {OrderId}")] - static partial void LogDiscountApplied(ILogger logger, string orderId); + partial void LogDiscountApplied(string orderId); [LoggerMessage(LogLevel.Information, "Successfully processed order {OrderId} with total amount {TotalAmount:c}")] - static partial void LogSuccessfullyProcessedOrder(ILogger logger, string orderId, decimal totalAmount); + partial void LogSuccessfullyProcessedOrder(string orderId, decimal totalAmount); } diff --git a/examples/Workflow/WorkflowParallelFanOut/Program.cs b/examples/Workflow/WorkflowParallelFanOut/Program.cs index 1c7cc08af..e39e58b79 100644 --- a/examples/Workflow/WorkflowParallelFanOut/Program.cs +++ b/examples/Workflow/WorkflowParallelFanOut/Program.cs @@ -51,9 +51,9 @@ await daprWorkflowClient.WaitForWorkflowCompletionAsync(instanceId); var state = await daprWorkflowClient.GetWorkflowStateAsync(instanceId); -logger.LogInformation("Workflow {InstanceId} completed with status: {Status}", instanceId, state.RuntimeStatus); +logger.LogInformation("Workflow {InstanceId} completed with status: {Status}", instanceId, state?.RuntimeStatus); -if (state.ReadOutputAs() is { } results) +if (state?.ReadOutputAs() is { } results) { logger.LogInformation("Processing Results:"); logger.LogInformation("=================="); @@ -68,7 +68,7 @@ order.OrderId, order.TotalAmount, order.Status); } - if (failedOrders.Any()) + if (failedOrders.Count != 0) { logger.LogWarning("Failed orders ({FailedCount}):", failedOrders.Count); foreach (var order in failedOrders) diff --git a/examples/Workflow/WorkflowSubworkflow/Program.cs b/examples/Workflow/WorkflowSubworkflow/Program.cs index b3e71537f..9d58a17ea 100644 --- a/examples/Workflow/WorkflowSubworkflow/Program.cs +++ b/examples/Workflow/WorkflowSubworkflow/Program.cs @@ -36,7 +36,7 @@ await daprWorkflowClient.WaitForWorkflowCompletionAsync(instanceId); var state = await daprWorkflowClient.GetWorkflowStateAsync(instanceId); -Console.WriteLine($"Workflow {instanceId}, state: {state.RuntimeStatus}"); +Console.WriteLine($@"Workflow {instanceId}, state: {state?.RuntimeStatus}"); state = await daprWorkflowClient.GetWorkflowStateAsync($"{instanceId}-sub"); -Console.WriteLine($"Workflow {instanceId}-sub, state: {state.RuntimeStatus}"); +Console.WriteLine($@"Workflow {instanceId}-sub, state: {state?.RuntimeStatus}"); diff --git a/examples/Workflow/WorkflowTaskChaining/Program.cs b/examples/Workflow/WorkflowTaskChaining/Program.cs index 126eff605..c342c18d9 100644 --- a/examples/Workflow/WorkflowTaskChaining/Program.cs +++ b/examples/Workflow/WorkflowTaskChaining/Program.cs @@ -38,7 +38,7 @@ //Check health const int wfInput = 42; -Console.WriteLine(@"Workflow Started"); +Console.WriteLine("Workflow Started"); var instanceId = $"demo-workflow-{Guid.NewGuid().ToString()[..8]}"; @@ -46,19 +46,19 @@ await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(DemoWorkflow), instanceId, wfInput); //Get the status of the workflow -WorkflowState workflowState; +WorkflowState? workflowState; while (true) { workflowState = await daprWorkflowClient.GetWorkflowStateAsync(instanceId, true); - Console.WriteLine($@"Workflow status: {workflowState.RuntimeStatus}"); - if (workflowState.IsWorkflowCompleted) + Console.WriteLine($"Workflow status: {workflowState?.RuntimeStatus}"); + if (workflowState?.IsWorkflowCompleted == true) break; await Task.Delay(TimeSpan.FromSeconds(1)); } //Display the result from the workflow -var result = string.Join(" ", workflowState.ReadOutputAs() ?? Array.Empty()); -Console.WriteLine($@"Workflow result: {result}"); +var result = string.Join(" ", workflowState.ReadOutputAs() ?? []); +Console.WriteLine($"Workflow result: {result}"); diff --git a/src/Dapr.Common/AssemblyInfo.cs b/src/Dapr.Common/AssemblyInfo.cs index f6ee4249a..5875274af 100644 --- a/src/Dapr.Common/AssemblyInfo.cs +++ b/src/Dapr.Common/AssemblyInfo.cs @@ -25,6 +25,7 @@ [assembly: InternalsVisibleTo("Dapr.Messaging, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] [assembly: InternalsVisibleTo("Dapr.Extensions.Configuration, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] [assembly: InternalsVisibleTo("Dapr.Workflow, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] +[assembly: InternalsVisibleTo("Dapr.Workflow.Grpc, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] [assembly: InternalsVisibleTo("Dapr.Actors.AspNetCore.IntegrationTest, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] [assembly: InternalsVisibleTo("Dapr.Actors.AspNetCore.IntegrationTest.App, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] diff --git a/src/Dapr.TestContainers/Common/PortUtilities.cs b/src/Dapr.TestContainers/Common/PortUtilities.cs index 1d3387f89..0371bc59a 100644 --- a/src/Dapr.TestContainers/Common/PortUtilities.cs +++ b/src/Dapr.TestContainers/Common/PortUtilities.cs @@ -19,7 +19,7 @@ namespace Dapr.TestContainers.Common; /// /// Provides port-related utilities. /// -internal static class PortUtilities +public static class PortUtilities { /// /// Finds a port that's available to use. diff --git a/src/Dapr.TestContainers/Common/TestDirectoryManager.cs b/src/Dapr.TestContainers/Common/TestDirectoryManager.cs new file mode 100644 index 000000000..4563be8d2 --- /dev/null +++ b/src/Dapr.TestContainers/Common/TestDirectoryManager.cs @@ -0,0 +1,89 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +namespace Dapr.TestContainers.Common; + +/// +/// Provides test directory management utilities. +/// +public static class TestDirectoryManager +{ + // Use the system temp path as the base for cross-platform compatibility + private static readonly string BasePath = Path.GetTempPath(); + + /// + /// Creates a new test directory. + /// + /// Any optional prefix value to set on the directory name. + /// + public static string CreateTestDirectory(string prefix) + { + var folderName = $"{prefix}-{Guid.NewGuid():N}"; + var directoryPath = Path.Combine(BasePath, folderName); + + Directory.CreateDirectory(directoryPath); + + // For Linux/Unix: Ensure directory has appropriate permissions (777). + // This is crucial for Docker volume mounts where the container user might not match the host user. + if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux) || RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) + { + try + { + var processStartInfo = new ProcessStartInfo + { + FileName = "chmod", + Arguments = $"-R 777 \"{directoryPath}\"", + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + }; + + using var process = Process.Start(processStartInfo); + process?.WaitForExit(); + } + catch (Exception ex) + { + Console.WriteLine($"Warning: Failed to set permissions on {directoryPath}: {ex.Message}"); + } + } + + return directoryPath; + } + + /// + /// Attempts to delete the directory and all its contents. + /// + public static void CleanUpDirectory(string directoryPath) + { + if (string.IsNullOrWhiteSpace(directoryPath)) return; + + try + { + if (Directory.Exists(directoryPath)) + { + Directory.Delete(directoryPath, true); + } + } + catch (Exception ex) + { + // Log warning but don't crash; typical issue is file locking or racing containers + Console.WriteLine($"Warning: Failed to clean up directory {directoryPath}: {ex.Message}"); + } + } +} diff --git a/src/Dapr.TestContainers/Containers/Dapr/DaprSchedulerContainer.cs b/src/Dapr.TestContainers/Containers/Dapr/DaprSchedulerContainer.cs index 99c58fc5d..cec391c8a 100644 --- a/src/Dapr.TestContainers/Containers/Dapr/DaprSchedulerContainer.cs +++ b/src/Dapr.TestContainers/Containers/Dapr/DaprSchedulerContainer.cs @@ -32,8 +32,9 @@ public sealed class DaprSchedulerContainer : IAsyncStartable { private readonly IContainer _container; // Contains the data directory used by this instance of the Dapr scheduler service - private string _hostDataDir = Path.Combine(Path.GetTempPath(), $"dapr-scheduler-{Guid.NewGuid():N}"); - private string _containerName = $"scheduler-{Guid.NewGuid():N}"; + //private readonly string _hostDataDir = Path.Combine(Path.GetTempPath(), $"dapr-scheduler-{Guid.NewGuid():N}"); + private readonly string _testDirectory; + private readonly string _containerName = $"scheduler-{Guid.NewGuid():N}"; /// /// The internal network alias/name of the container. @@ -58,7 +59,7 @@ public sealed class DaprSchedulerContainer : IAsyncStartable public DaprSchedulerContainer(DaprRuntimeOptions options, INetwork network) { // Scheduler service runs via port 51005 - const string containerDataDir = "/tmp/dapr-scheduler"; + const string containerDataDir = "/data/dapr-scheduler"; string[] cmd = [ "./scheduler", @@ -66,17 +67,16 @@ public DaprSchedulerContainer(DaprRuntimeOptions options, INetwork network) "--etcd-data-dir", containerDataDir ]; - //Create a unique temp directory on the host for the test run based on this instance's data directory - Directory.CreateDirectory(_hostDataDir); + _testDirectory = TestDirectoryManager.CreateTestDirectory("scheduler"); _container = new ContainerBuilder() - .WithImage(options.SchedulerImageTag) + .WithImage(options.SchedulerImageTag) .WithName(_containerName) .WithNetwork(network) .WithCommand(cmd.ToArray()) .WithPortBinding(InternalPort, assignRandomHostPort: true) // Mount an anonymous volume to /data to ensure the scheduler has write permissions - .WithBindMount(_hostDataDir, containerDataDir, AccessMode.ReadWrite) + .WithBindMount(_testDirectory, containerDataDir, AccessMode.ReadWrite) .WithWaitStrategy(Wait.ForUnixContainer().UntilMessageIsLogged("api is ready")) .Build(); } @@ -86,12 +86,6 @@ public async Task StartAsync(CancellationToken cancellationToken = default) { await _container.StartAsync(cancellationToken); ExternalPort = _container.GetMappedPublicPort(InternalPort); - - // Empty dirs with 0777 inside container: - await _container.ExecAsync(["sh", "-c", "mkdir -p ./default-dapr-scheduler-server-0/dapr-0.1 && chmod 0777 ./default-dapr-scheduler-server-0/dapr-0.1" - ], cancellationToken); - await _container.ExecAsync(["sh", "-c", "mkdir -p ./dapr-scheduler-existing-cluster && chmod 0777 ./dapr-scheduler-existing-cluster" - ], cancellationToken); } /// @@ -100,8 +94,10 @@ await _container.ExecAsync(["sh", "-c", "mkdir -p ./dapr-scheduler-existing-clus public ValueTask DisposeAsync() { // Remove the data directory if it exists - if (Directory.Exists(_hostDataDir)) - Directory.Delete(_hostDataDir, true); + TestDirectoryManager.CleanUpDirectory(_testDirectory); + + // if (Directory.Exists(_hostDataDir)) + // Directory.Delete(_hostDataDir, true); return _container.DisposeAsync(); } } diff --git a/src/Dapr.TestContainers/Harnesses/BaseHarness.cs b/src/Dapr.TestContainers/Harnesses/BaseHarness.cs index b19589b90..717951d66 100644 --- a/src/Dapr.TestContainers/Harnesses/BaseHarness.cs +++ b/src/Dapr.TestContainers/Harnesses/BaseHarness.cs @@ -149,7 +149,7 @@ public virtual async ValueTask DisposeAsync() await Network.DisposeAsync(); // Clean up generated YAML files - CleanupComponents(ComponentsDirectory); + TestDirectoryManager.CleanUpDirectory(ComponentsDirectory); GC.SuppressFinalize(this); } diff --git a/src/Dapr.TestContainers/Harnesses/WorkflowHarness.cs b/src/Dapr.TestContainers/Harnesses/WorkflowHarness.cs index 6d869af00..95bbbcad7 100644 --- a/src/Dapr.TestContainers/Harnesses/WorkflowHarness.cs +++ b/src/Dapr.TestContainers/Harnesses/WorkflowHarness.cs @@ -55,7 +55,9 @@ protected override async Task OnInitializeAsync(CancellationToken cancellationTo // Set the service ports this.DaprPlacementExternalPort = _placement.ExternalPort; + this.DaprPlacementAlias = _placement.NetworkAlias; this.DaprSchedulerExternalPort = _scheduler.ExternalPort; + this.DaprSchedulerAlias = _scheduler.NetworkAlias; } /// diff --git a/src/Dapr.Workflow.Abstractions/AssemblyInfo.cs b/src/Dapr.Workflow.Abstractions/AssemblyInfo.cs new file mode 100644 index 000000000..327829b99 --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/AssemblyInfo.cs @@ -0,0 +1,18 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Dapr.Workflow.Abstractions.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] + diff --git a/src/Dapr.Workflow.Abstractions/Attributes/WorkflowActivityAttribute.cs b/src/Dapr.Workflow.Abstractions/Attributes/WorkflowActivityAttribute.cs new file mode 100644 index 000000000..3aa754197 --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/Attributes/WorkflowActivityAttribute.cs @@ -0,0 +1,47 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Abstractions.Attributes; + +/// +/// Marks a class as a workflow activity implementation. +/// +/// +/// This attribute can be used by source generators to automatically discovery and register +/// activities at compile time. It can also be used for runtime discovery and validation. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class WorkflowActivityAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public WorkflowActivityAttribute() + { + } + + /// + /// Initializes a new instance of the with a specific name. + /// + /// The name to register this activity under. If not specified, the class name + /// is used. + public WorkflowActivityAttribute(string name) + { + Name = name; + } + + /// + /// Get the name to register this activity under. + /// + public string? Name { get; } +} diff --git a/src/Dapr.Workflow.Abstractions/Attributes/WorkflowAttribute.cs b/src/Dapr.Workflow.Abstractions/Attributes/WorkflowAttribute.cs new file mode 100644 index 000000000..31aa28010 --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/Attributes/WorkflowAttribute.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Abstractions.Attributes; + +/// +/// Marks a class as a workflow implementation. +/// +/// +/// This attribute can be used by source generators to automatically discover and register workflows +/// as compile time. It can also be used for runtime discovery and validation. +/// +[AttributeUsage(AttributeTargets.Class, AllowMultiple = false, Inherited = false)] +public sealed class WorkflowAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public WorkflowAttribute() + { + } + + /// + /// Initialies a new instance of the class with a specific name. + /// + /// The name to reigster this workflow under. If not specified, the class name is used. + public WorkflowAttribute(string name) + { + Name = name; + } + + /// + /// Gets the name to register this workflow under. + /// + public string? Name { get; } +} diff --git a/src/Dapr.Workflow.Abstractions/Dapr.Workflow.Abstractions.csproj b/src/Dapr.Workflow.Abstractions/Dapr.Workflow.Abstractions.csproj new file mode 100644 index 000000000..adbccde9f --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/Dapr.Workflow.Abstractions.csproj @@ -0,0 +1,15 @@ + + + + enable + enable + Dapr.Workflow.Abstractions + Dapr Workflow Abstractions + Provides abstractions for the Dapr Workflow SDK implementation. + + + + + + + diff --git a/src/Dapr.Workflow/WorkflowTaskFailedException.cs b/src/Dapr.Workflow.Abstractions/Exceptions/WorkflowTaskFailedException.cs similarity index 95% rename from src/Dapr.Workflow/WorkflowTaskFailedException.cs rename to src/Dapr.Workflow.Abstractions/Exceptions/WorkflowTaskFailedException.cs index cd7600f9c..ce5826cc5 100644 --- a/src/Dapr.Workflow/WorkflowTaskFailedException.cs +++ b/src/Dapr.Workflow.Abstractions/Exceptions/WorkflowTaskFailedException.cs @@ -20,8 +20,7 @@ namespace Dapr.Workflow; /// /// The exception message. /// Details about the failure. -public class WorkflowTaskFailedException(string message, WorkflowTaskFailureDetails failureDetails) - : Exception(message) +public class WorkflowTaskFailedException(string message, WorkflowTaskFailureDetails failureDetails) : Exception(message) { /// /// Gets more information about the underlying workflow task failure. diff --git a/src/Dapr.Workflow.Abstractions/IWorkflow.cs b/src/Dapr.Workflow.Abstractions/IWorkflow.cs new file mode 100644 index 000000000..cfcffa767 --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/IWorkflow.cs @@ -0,0 +1,42 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +/// +/// Common interface for workflow implementations. +/// +/// +/// Users should not implement workflows using this interface, directly. +/// Instead, should be used to implement workflows. +/// +public interface IWorkflow +{ + /// + /// Gets the type of the input parameter that this workflow accepts. + /// + Type InputType { get; } + + /// + /// Gets the type of the return value that this workflow produces. + /// + Type OutputType { get; } + + /// + /// Invokes the workflow with the specified context and input. + /// + /// The workflow's context. + /// The workflow's input. + /// Returns the workflow output as the result of a . + Task RunAsync(WorkflowContext context, object? input); +} diff --git a/src/Dapr.Workflow.Abstractions/IWorkflowActivity.cs b/src/Dapr.Workflow.Abstractions/IWorkflowActivity.cs new file mode 100644 index 000000000..e304ad41d --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/IWorkflowActivity.cs @@ -0,0 +1,42 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Abstractions; + +/// +/// Common interface for workflow activity implementations. +/// +/// +/// Users should not implement workflow activities using this interface, directly. +/// Instead, should be used to implement workflow activities. +/// +public interface IWorkflowActivity +{ + /// + /// Gets the type of the input parameter that this workflow accepts. + /// + Type InputType { get; } + + /// + /// Gets the type of the return value this workflow produces. + /// + Type OutputType { get; } + + /// + /// Invokes the workflow activity with the specified context and input. + /// + /// The activity's context. + /// The activity's input. + /// Returns the workflow activity output as the result of a . + Task RunAsync(WorkflowActivityContext context, object? input); +} diff --git a/src/Dapr.Workflow/IWorkflowContext.cs b/src/Dapr.Workflow.Abstractions/IWorkflowContext.cs similarity index 52% rename from src/Dapr.Workflow/IWorkflowContext.cs rename to src/Dapr.Workflow.Abstractions/IWorkflowContext.cs index 7bbbb4f94..fe297c2c9 100644 --- a/src/Dapr.Workflow/IWorkflowContext.cs +++ b/src/Dapr.Workflow.Abstractions/IWorkflowContext.cs @@ -1,4 +1,17 @@ -namespace Dapr.Workflow; +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; /// /// Provides functionality available to orchestration code. diff --git a/src/Dapr.Workflow.Abstractions/TaskIdentifier.cs b/src/Dapr.Workflow.Abstractions/TaskIdentifier.cs new file mode 100644 index 000000000..275922cf5 --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/TaskIdentifier.cs @@ -0,0 +1,36 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Abstractions; + +/// +/// Identifies a workflow task (workflow or activity). +/// +/// The name of the task. +public readonly record struct TaskIdentifier(string Name) +{ + /// + /// Implicitly converts a string to a . + /// + /// The task name. + public static implicit operator TaskIdentifier(string name) => new (name); + + /// + /// Implicitly converts a to a string. + /// + /// The task identifier. + public static implicit operator string(TaskIdentifier identifier) => identifier.Name; + + /// + public override string ToString() => Name; +} diff --git a/src/Dapr.Workflow/Workflow.cs b/src/Dapr.Workflow.Abstractions/Workflow.cs similarity index 85% rename from src/Dapr.Workflow/Workflow.cs rename to src/Dapr.Workflow.Abstractions/Workflow.cs index b8d43b35c..fb6a113fa 100644 --- a/src/Dapr.Workflow/Workflow.cs +++ b/src/Dapr.Workflow.Abstractions/Workflow.cs @@ -16,34 +16,6 @@ namespace Dapr.Workflow; using System; using System.Threading.Tasks; -/// -/// Common interface for workflow implementations. -/// -/// -/// Users should not implement workflows using this interface, directly. -/// Instead, should be used to implement workflows. -/// -public interface IWorkflow -{ - /// - /// Gets the type of the input parameter that this workflow accepts. - /// - Type InputType { get; } - - /// - /// Gets the type of the return value that this workflow produces. - /// - Type OutputType { get; } - - /// - /// Invokes the workflow with the specified context and input. - /// - /// The workflow's context. - /// The workflow's input. - /// Returns the workflow output as the result of a . - Task RunAsync(WorkflowContext context, object? input); -} - /// /// Represents the base class for workflows. /// @@ -133,4 +105,4 @@ public abstract class Workflow : IWorkflow /// The deserialized workflow input. /// The output of the workflow as a task. public abstract Task RunAsync(WorkflowContext context, TInput input); -} \ No newline at end of file +} diff --git a/src/Dapr.Workflow/WorkflowActivity.cs b/src/Dapr.Workflow.Abstractions/WorkflowActivity.cs similarity index 71% rename from src/Dapr.Workflow/WorkflowActivity.cs rename to src/Dapr.Workflow.Abstractions/WorkflowActivity.cs index ff13de1c2..7f6c181e9 100644 --- a/src/Dapr.Workflow/WorkflowActivity.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowActivity.cs @@ -1,5 +1,5 @@ // ------------------------------------------------------------------------ -// Copyright 2023 The Dapr Authors +// Copyright 2025 The Dapr Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -13,37 +13,10 @@ namespace Dapr.Workflow; +using Abstractions; using System; using System.Threading.Tasks; -/// -/// Common interface for workflow activity implementations. -/// -/// -/// Users should not implement workflow activities using this interface, directly. -/// Instead, should be used to implement workflow activities. -/// -public interface IWorkflowActivity -{ - /// - /// Gets the type of the input parameter that this activity accepts. - /// - Type InputType { get; } - - /// - /// Gets the type of the return value that this activity produces. - /// - Type OutputType { get; } - - /// - /// Invokes the workflow activity with the specified context and input. - /// - /// The workflow activity's context. - /// The workflow activity's input. - /// Returns the workflow activity output as the result of a . - Task RunAsync(WorkflowActivityContext context, object? input); -} - /// /// Base class for workflow activities. /// @@ -77,7 +50,7 @@ public abstract class WorkflowActivity : IWorkflowActivity Type IWorkflowActivity.OutputType => typeof(TOutput); /// - async Task IWorkflowActivity.RunAsync(WorkflowActivityContext context, object? input) + public async Task RunAsync(WorkflowActivityContext context, object? input) { return await this.RunAsync(context, (TInput)input!); } @@ -89,4 +62,4 @@ public abstract class WorkflowActivity : IWorkflowActivity /// The deserialized activity input. /// The output of the activity as a task. public abstract Task RunAsync(WorkflowActivityContext context, TInput input); -} \ No newline at end of file +} diff --git a/src/Dapr.Workflow/WorkflowActivityContext.cs b/src/Dapr.Workflow.Abstractions/WorkflowActivityContext.cs similarity index 81% rename from src/Dapr.Workflow/WorkflowActivityContext.cs rename to src/Dapr.Workflow.Abstractions/WorkflowActivityContext.cs index 691c1af9e..c7df69683 100644 --- a/src/Dapr.Workflow/WorkflowActivityContext.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowActivityContext.cs @@ -1,5 +1,5 @@ // ------------------------------------------------------------------------ -// Copyright 2022 The Dapr Authors +// Copyright 2025 The Dapr Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -11,9 +11,9 @@ // limitations under the License. // ------------------------------------------------------------------------ -namespace Dapr.Workflow; +using Dapr.Workflow.Abstractions; -using Dapr.DurableTask; +namespace Dapr.Workflow; /// /// Defines properties and methods for task activity context objects. @@ -23,7 +23,12 @@ public abstract class WorkflowActivityContext /// /// Gets the name of the activity. /// - public abstract TaskName Name { get; } + public abstract TaskIdentifier Identifier { get; } + + /// + /// Gets the task execution key. + /// + public abstract string TaskExecutionKey { get; } /// /// Gets the unique ID of the current workflow instance. diff --git a/src/Dapr.Workflow/WorkflowContext.cs b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs similarity index 99% rename from src/Dapr.Workflow/WorkflowContext.cs rename to src/Dapr.Workflow.Abstractions/WorkflowContext.cs index 4c25c9af8..acd4eeb57 100644 --- a/src/Dapr.Workflow/WorkflowContext.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowContext.cs @@ -1,5 +1,5 @@ // ------------------------------------------------------------------------ -// Copyright 2022 The Dapr Authors +// Copyright 2025 The Dapr Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -11,10 +11,10 @@ // limitations under the License. // ------------------------------------------------------------------------ -using Microsoft.Extensions.Logging; namespace Dapr.Workflow; +using Microsoft.Extensions.Logging; using System; using System.Threading; using System.Threading.Tasks; @@ -333,4 +333,4 @@ public virtual Task CallChildWorkflowAsync( /// /// The new value. public abstract Guid NewGuid(); -} \ No newline at end of file +} diff --git a/src/Dapr.Workflow.Abstractions/WorkflowRetryPolicy.cs b/src/Dapr.Workflow.Abstractions/WorkflowRetryPolicy.cs new file mode 100644 index 000000000..cddb4674d --- /dev/null +++ b/src/Dapr.Workflow.Abstractions/WorkflowRetryPolicy.cs @@ -0,0 +1,127 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow; + +/// +/// A declarative retry policy that can be configured for activity or child workflow calls. +/// +public class WorkflowRetryPolicy +{ + /// + /// A declarative retry policy that can be configured for activity or child workflow calls. + /// + /// The maximum number of task invocation attempts. Must be 1 or greater. + /// The amount of time to delay between the first and second attempt. + /// + /// The exponential back-off coefficient used to determine the delay between subsequent retries. Must be 1.0 or greater. + /// + /// + /// The maximum time to delay between attempts, regardless of. + /// + /// The overall timeout for retries. + /// + /// The value can be used to specify an unlimited timeout for + /// or . + /// + /// + /// Thrown if any of the following are true: + /// + /// The value for is less than or equal to zero. + /// The value for is less than or equal to . + /// The value for is less than 1.0. + /// The value for is less than . + /// The value for is less than . + /// + /// + public WorkflowRetryPolicy(int maxNumberOfAttempts, + TimeSpan firstRetryInterval, + double backoffCoefficient = 1.0, + TimeSpan? maxRetryInterval = null, + TimeSpan? retryTimeout = null) + { + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(maxNumberOfAttempts, 0, nameof(maxNumberOfAttempts)); + ArgumentOutOfRangeException.ThrowIfLessThanOrEqual(firstRetryInterval, TimeSpan.Zero, nameof(firstRetryInterval)); + ArgumentOutOfRangeException.ThrowIfLessThan(backoffCoefficient, 1.0, nameof(backoffCoefficient)); + + var resolvedMaxRetryInterval = maxRetryInterval ?? TimeSpan.FromHours(1); + ArgumentOutOfRangeException.ThrowIfLessThan(resolvedMaxRetryInterval, firstRetryInterval, nameof(maxRetryInterval)); + + var resolvedRetryTimeout = retryTimeout ?? Timeout.InfiniteTimeSpan; + if (resolvedRetryTimeout != Timeout.InfiniteTimeSpan && resolvedRetryTimeout < firstRetryInterval) + throw new ArgumentOutOfRangeException(nameof(retryTimeout), retryTimeout, + $"The retry timeout value must be greater than or equal to the first retry interval value of {firstRetryInterval}"); + + this.MaxNumberOfAttempts = maxNumberOfAttempts; + this.FirstRetryInterval = firstRetryInterval; + this.BackoffCoefficient = backoffCoefficient; + this.MaxRetryInterval = maxRetryInterval; + this.RetryTimeout = resolvedRetryTimeout; + } + + /// + /// Gets the max number of attempts for executing a given task. + /// + public int MaxNumberOfAttempts { get; } + + /// + /// Gets the amount of time to delay between the first and second attempt. + /// + public TimeSpan FirstRetryInterval { get; } + + /// + /// Gets the exponential back-off coefficient used to determine the delay between subsequent retries. + /// + /// + /// Defaults to 1.0 for no back-off. + /// + public double BackoffCoefficient { get; } + + /// + /// Gets the maximum time to delay between attempts. + /// + /// + /// Defaults to 1 hour. + /// + public TimeSpan? MaxRetryInterval { get; } + + /// + /// Gets the overall timeout for retries. No further attempts will be made at executing a task after this retry + /// timeout expires. + /// + /// + /// Defaults to . + /// + public TimeSpan RetryTimeout { get; } + + /// + /// Calculates the next retry delay based on the attempt number. + /// + /// The current attempt number (1-indexed). + /// The delay to wait before the next retry attempt. + internal TimeSpan GetNextDelay(int attemptNumber) + { + if (attemptNumber <= 0) + return TimeSpan.Zero; + + // Calculate: firstRetryInterval * (backoffCoefficient ^ (attemptNumber - 1)) + var nextDelayInMilliseconds = this.FirstRetryInterval.TotalMilliseconds * + Math.Pow(this.BackoffCoefficient, attemptNumber - 1); + + var nextDelay = TimeSpan.FromMilliseconds(nextDelayInMilliseconds); + + // Cap at max retry interval + return this.MaxRetryInterval is null ? nextDelay : + nextDelay > this.MaxRetryInterval ? (TimeSpan)this.MaxRetryInterval : nextDelay; + } +} diff --git a/src/Dapr.Workflow/WorkflowRuntimeStatus.cs b/src/Dapr.Workflow.Abstractions/WorkflowRuntimeStatus.cs similarity index 78% rename from src/Dapr.Workflow/WorkflowRuntimeStatus.cs rename to src/Dapr.Workflow.Abstractions/WorkflowRuntimeStatus.cs index 9cc0942f8..aef4ccfa0 100644 --- a/src/Dapr.Workflow/WorkflowRuntimeStatus.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowRuntimeStatus.cs @@ -26,30 +26,45 @@ public enum WorkflowRuntimeStatus /// /// The workflow started running. /// - Running, + Running = 0, /// /// The workflow completed normally. /// - Completed, - + Completed = 1, + + /// + /// The workflow was continued as a new workflow. + /// + ContinuedAsNew = 2, + /// /// The workflow completed with an unhandled exception. /// - Failed, + Failed = 3, + + /// + /// The workflow was canceled. + /// + Canceled = 4, /// /// The workflow was abruptly terminated via a management API call. /// - Terminated, + Terminated = 5, /// /// The workflow was scheduled but hasn't started running. /// - Pending, + Pending = 6, /// /// The workflow was suspended. /// - Suspended, -} \ No newline at end of file + Suspended = 7, + + /// + /// The workflow was stalled. + /// + Stalled = 8 +} diff --git a/src/Dapr.Workflow/WorkflowTaskFailureDetails.cs b/src/Dapr.Workflow.Abstractions/WorkflowTaskFailureDetails.cs similarity index 60% rename from src/Dapr.Workflow/WorkflowTaskFailureDetails.cs rename to src/Dapr.Workflow.Abstractions/WorkflowTaskFailureDetails.cs index ec75ee04d..1a2edbd2b 100644 --- a/src/Dapr.Workflow/WorkflowTaskFailureDetails.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowTaskFailureDetails.cs @@ -1,5 +1,5 @@ // ------------------------------------------------------------------------ -// Copyright 2023 The Dapr Authors +// Copyright 2025 The Dapr Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -14,34 +14,26 @@ namespace Dapr.Workflow; using System; -using Dapr.DurableTask; /// /// Represents workflow task failure details. /// -public class WorkflowTaskFailureDetails +public class WorkflowTaskFailureDetails(string errorType, string errorMessage, string? stackTrace = null) { - readonly TaskFailureDetails details; - - internal WorkflowTaskFailureDetails(TaskFailureDetails details) - { - this.details = details ?? throw new ArgumentNullException(nameof(details)); - } - /// /// Gets the error type, which is the namespace-qualified exception type name. /// - public string ErrorType => this.details.ErrorType; + public string ErrorType => errorType ?? throw new ArgumentNullException(errorType, nameof(errorType)); /// /// Gets a summary description of the failure, which is typically an exception message. /// - public string ErrorMessage => this.details.ErrorMessage; + public string ErrorMessage => errorMessage ?? throw new ArgumentNullException(errorMessage, nameof(errorMessage)); /// /// Gets the stack trace of the failure. /// - public string? StackTrace => this.details.StackTrace; + public string? StackTrace => stackTrace; /// /// Returns true if the failure was caused by the specified exception type. @@ -57,15 +49,36 @@ internal WorkflowTaskFailureDetails(TaskFailureDetails details) /// public bool IsCausedBy() where T : Exception { - return this.details.IsCausedBy(); + try + { + Type? exceptionType = Type.GetType(this.ErrorType, throwOnError: false); + return exceptionType is not null && typeof(T).IsAssignableFrom(exceptionType); + } + catch + { + // If we can't load the type for any reason, return false + return false; + } } /// /// Gets a debug-friendly description of the failure information. /// /// A debugger friendly display string. - public override string ToString() + public override string ToString() => $"{this.ErrorType}: {this.ErrorMessage}"; + + /// + /// Creates a from an exception. + /// + /// The exception to convert. + /// A new instance of . + internal static WorkflowTaskFailureDetails FromException(Exception ex) { - return $"{this.ErrorType}: {this.ErrorMessage}"; + ArgumentNullException.ThrowIfNull(ex); + + return new WorkflowTaskFailureDetails( + ex.GetType().FullName ?? ex.GetType().Name, + ex.Message, + ex.StackTrace); } } diff --git a/src/Dapr.Workflow/WorkflowTaskOptions.cs b/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs similarity index 64% rename from src/Dapr.Workflow/WorkflowTaskOptions.cs rename to src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs index eeac2e304..04c24ef92 100644 --- a/src/Dapr.Workflow/WorkflowTaskOptions.cs +++ b/src/Dapr.Workflow.Abstractions/WorkflowTaskOptions.cs @@ -11,43 +11,20 @@ // limitations under the License. // ------------------------------------------------------------------------ -using Dapr.DurableTask; - namespace Dapr.Workflow; /// /// Options that can be used to control the behavior of workflow task execution. /// /// The workflow retry policy. -public record WorkflowTaskOptions(WorkflowRetryPolicy? RetryPolicy = null) -{ - internal TaskOptions ToDurableTaskOptions() - { - TaskRetryOptions? retryOptions = null; - if (this.RetryPolicy is not null) - { - retryOptions = this.RetryPolicy.GetDurableRetryPolicy(); - } - - return new TaskOptions(retryOptions); - } -} +/// The App ID indicating the app in which to find the named activity to run. +public record WorkflowTaskOptions(WorkflowRetryPolicy? RetryPolicy = null, string? AppId = null); /// /// Options for controlling the behavior of child workflow execution. /// /// The instance ID to use for the child workflow. /// The child workflow's retry policy. -public record ChildWorkflowTaskOptions(string? InstanceId = null, WorkflowRetryPolicy? RetryPolicy = null) : WorkflowTaskOptions(RetryPolicy) -{ - internal new SubOrchestrationOptions ToDurableTaskOptions() - { - TaskRetryOptions? retryOptions = null; - if (this.RetryPolicy is not null) - { - retryOptions = this.RetryPolicy.GetDurableRetryPolicy(); - } - - return new SubOrchestrationOptions(retryOptions, this.InstanceId); - } -} +/// The App ID indicating the app in which to find the named child workflow to run. +public record ChildWorkflowTaskOptions(string? InstanceId = null, WorkflowRetryPolicy? RetryPolicy = null, string? AppId = null) + : WorkflowTaskOptions(RetryPolicy, AppId); diff --git a/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs index 91593c8ff..c3677fab6 100644 --- a/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs +++ b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationAnalyzer.cs @@ -41,49 +41,41 @@ private static void AnalyzeWorkflowRegistration(SyntaxNodeAnalysisContext contex var invocationExpr = (InvocationExpressionSyntax)context.Node; if (invocationExpr.Expression is not MemberAccessExpressionSyntax memberAccessExpr) - { return; - } if (memberAccessExpr.Name.Identifier.Text != "ScheduleNewWorkflowAsync") - { return; - } var argumentList = invocationExpr.ArgumentList.Arguments; if (argumentList.Count == 0) - { return; - } var firstArgument = argumentList[0].Expression; - if (firstArgument is not InvocationExpressionSyntax nameofInvocation) - { + if (firstArgument is not InvocationExpressionSyntax nameofInvocation || + nameofInvocation.Expression is not IdentifierNameSyntax { Identifier.Text : "nameof"} || + nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression is not {} nameofArgExpr) return; - } - - var workflowName = nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression.ToString().Trim('"'); - if (workflowName == null) - { + + if (context.SemanticModel.GetSymbolInfo(nameofArgExpr, context.CancellationToken).Symbol is not INamedTypeSymbol workflowTypeSymbol) return; - } - - bool isRegistered = CheckIfWorkflowIsRegistered(workflowName, context.SemanticModel); + + var isRegistered = CheckIfWorkflowIsRegistered(workflowTypeSymbol, context.SemanticModel, context.CancellationToken); if (isRegistered) { return; } - + + var workflowName = workflowTypeSymbol.Name; var diagnostic = Diagnostic.Create(WorkflowDiagnosticDescriptor, firstArgument.GetLocation(), workflowName); context.ReportDiagnostic(diagnostic); } - private static bool CheckIfWorkflowIsRegistered(string workflowName, SemanticModel semanticModel) + private static bool CheckIfWorkflowIsRegistered(INamedTypeSymbol workflowType, SemanticModel semanticModel, CancellationToken cancellationToken) { var methodInvocations = new List(); foreach (var syntaxTree in semanticModel.Compilation.SyntaxTrees) { - var root = syntaxTree.GetRoot(); + var root = syntaxTree.GetRoot(cancellationToken); methodInvocations.AddRange(root.DescendantNodes().OfType()); } @@ -94,24 +86,21 @@ private static bool CheckIfWorkflowIsRegistered(string workflowName, SemanticMod continue; } - var methodName = memberAccess.Name.Identifier.Text; - if (methodName != "RegisterWorkflow") - { - continue; - } - - if (memberAccess.Name is not GenericNameSyntax typeArgumentList || - typeArgumentList.TypeArgumentList.Arguments.Count <= 0) + if (memberAccess.Name is not GenericNameSyntax genericName || + genericName.Identifier.Text != "RegisterWorkflow" || + genericName.TypeArgumentList.Arguments.Count == 0) { continue; } - if (typeArgumentList.TypeArgumentList.Arguments[0] is not IdentifierNameSyntax typeArgument) + var typeArgSyntax = genericName.TypeArgumentList.Arguments[0]; + var typeArgSymbol = semanticModel.GetSymbolInfo(typeArgSyntax, cancellationToken).Symbol as INamedTypeSymbol; + if (typeArgSymbol is null) { continue; } - if (typeArgument.Identifier.Text == workflowName) + if (SymbolEqualityComparer.Default.Equals(typeArgSymbol, workflowType)) { return true; } diff --git a/src/Dapr.Workflow.Analyzers/WorkflowRegistrationCodeFixProvider.cs b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationCodeFixProvider.cs index 34aebca3d..66dca5bde 100644 --- a/src/Dapr.Workflow.Analyzers/WorkflowRegistrationCodeFixProvider.cs +++ b/src/Dapr.Workflow.Analyzers/WorkflowRegistrationCodeFixProvider.cs @@ -34,68 +34,65 @@ public override Task RegisterCodeFixesAsync(CodeFixContext context) return Task.CompletedTask; } - private async Task RegisterWorkflowAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) + private static async Task RegisterWorkflowAsync(Document document, Diagnostic diagnostic, CancellationToken cancellationToken) { var root = await document.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); var diagnosticSpan = diagnostic.Location.SourceSpan; - var oldInvocation = root?.FindToken(diagnosticSpan.Start).Parent?.AncestorsAndSelf().OfType().First(); + // Prefer the ScheduleNewWorkflowAsync(...) invocation even if the diagnostic is reported on nameof(...) + var invocationsAtLocation = root? + .FindToken(diagnosticSpan.Start) + .Parent? + .AncestorsAndSelf() + .OfType() + .ToList(); + + var oldInvocation = invocationsAtLocation? + .FirstOrDefault(invocation => + invocation.Expression is MemberAccessExpressionSyntax memberAccessExpr && + memberAccessExpr.Name.Identifier.Text == "ScheduleNewWorkflowAsync") + ?? invocationsAtLocation?.FirstOrDefault(); if (oldInvocation is null || root is null) - { return document; - } // Get the semantic model var semanticModel = await document.GetSemanticModelAsync(cancellationToken); - - // Extract the workflow type name - var workflowTypeSyntax = oldInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression; - - if (workflowTypeSyntax == null) - { + if (semanticModel is null) return document; - } + // Extract the workflow type name from nameof(SomeWorkflow) + var firstArgExpr = oldInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression; + if (firstArgExpr is not InvocationExpressionSyntax nameofInvocation || + nameofInvocation.Expression is not IdentifierNameSyntax { Identifier.Text: "nameof" } || + nameofInvocation.ArgumentList.Arguments.FirstOrDefault()?.Expression is not { } nameofArgExpr) + return document; + // Get the symbol for the workflow type - if (semanticModel.GetSymbolInfo(workflowTypeSyntax, cancellationToken).Symbol is not INamedTypeSymbol - workflowTypeSymbol) - { + if (semanticModel.GetSymbolInfo(nameofArgExpr, cancellationToken).Symbol is not INamedTypeSymbol workflowTypeSymbol) return document; - } // Get the fully qualified name var workflowType = workflowTypeSymbol.ToDisplayString(new SymbolDisplayFormat( typeQualificationStyle: SymbolDisplayTypeQualificationStyle.NameAndContainingTypesAndNamespaces)); if (string.IsNullOrEmpty(workflowType)) - { return document; - } - - // Get the compilation - var compilation = await document.Project.GetCompilationAsync(cancellationToken); - - if (compilation == null) - { - return document; - } - var (targetDocument, addDaprWorkflowInvocation) = await FindAddDaprWorkflowInvocationAsync(document.Project, cancellationToken); + var (targetDocument, addDaprWorkflowInvocation) = + await FindAddDaprWorkflowInvocationAsync(document.Project, cancellationToken); if (addDaprWorkflowInvocation == null) { - (targetDocument, addDaprWorkflowInvocation) = await CreateAddDaprWorkflowInvocation(document.Project, cancellationToken); + (targetDocument, addDaprWorkflowInvocation) = + await CreateAddDaprWorkflowInvocation(document.Project, cancellationToken); } - if (addDaprWorkflowInvocation == null) - { + if (addDaprWorkflowInvocation == null || targetDocument == null) return document; - } var targetRoot = await addDaprWorkflowInvocation.SyntaxTree.GetRootAsync(cancellationToken); - - if (targetRoot == null || targetDocument == null) + if (targetRoot == null) return document; // Find the options lambda block @@ -120,7 +117,7 @@ private async Task RegisterWorkflowAsync(Document document, Diagnostic var newRoot = targetRoot.ReplaceNode(optionsBlock, newOptionsBlock); // Format the new root. - newRoot = Formatter.Format(newRoot, document.Project.Solution.Workspace); + newRoot = Formatter.Format(newRoot, targetDocument.Project.Solution.Workspace); return targetDocument.WithSyntaxRoot(newRoot); } @@ -129,10 +126,7 @@ private async Task RegisterWorkflowAsync(Document document, Diagnostic /// Gets the FixAllProvider for this code fix provider. /// /// The FixAllProvider instance. - public override FixAllProvider? GetFixAllProvider() - { - return WellKnownFixAllProviders.BatchFixer; - } + public override FixAllProvider? GetFixAllProvider() => WellKnownFixAllProviders.BatchFixer; private static async Task<(Document?, InvocationExpressionSyntax?)> FindAddDaprWorkflowInvocationAsync(Project project, CancellationToken cancellationToken) { @@ -141,7 +135,7 @@ private async Task RegisterWorkflowAsync(Document document, Diagnostic foreach (var syntaxTree in compilation!.SyntaxTrees) { var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken); - + var addDaprWorkflowInvocation = syntaxRoot.DescendantNodes() .OfType() .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && @@ -159,56 +153,160 @@ private async Task RegisterWorkflowAsync(Document document, Diagnostic return (null, null); } - private async Task<(Document?, InvocationExpressionSyntax?)> CreateAddDaprWorkflowInvocation(Project project, CancellationToken cancellationToken) + private static async Task<(Document?, InvocationExpressionSyntax?)> CreateAddDaprWorkflowInvocation(Project project, CancellationToken cancellationToken) { + // Case 1/2 : var builder = WebApplication.CreateBuilder(...); + // var builder = Host.CreateApplicationBuilder(...); var createBuilderInvocation = await FindCreateBuilderInvocationAsync(project, cancellationToken); + if (createBuilderInvocation != null) + { + var variableDeclarator = createBuilderInvocation.Ancestors() + .OfType() + .FirstOrDefault(); + + var builderVariable = variableDeclarator?.Identifier.Text; + if (string.IsNullOrWhiteSpace(builderVariable)) + return (null, null); + + var targetRoot = await createBuilderInvocation.SyntaxTree.GetRootAsync(cancellationToken); + var document = project.GetDocument(createBuilderInvocation.SyntaxTree); + if (targetRoot == null || document == null) + return (null, null); + + // Force a block-bodied lambda so formatting is stable and matches tests. + var addDaprWorkflowStatement = SyntaxFactory.ParseStatement( + $"{builderVariable}.Services.AddDaprWorkflow(options =>\n{{\n}});\n"); + + // Insert immediately after the statement containing the builder creation. + // Handles: + // - inside a method/body (BlockSyntax) + // - top-level statements (GlobalStatementSyntax -> CompilationUnitSyntax) + var containingStatement = createBuilderInvocation.Ancestors().OfType().FirstOrDefault(); + if (containingStatement is null) + return (null, null); + + if (containingStatement.Parent is BlockSyntax block) + { + var newBlock = block.InsertNodesAfter(containingStatement, [addDaprWorkflowStatement]); + targetRoot = targetRoot.ReplaceNode(block, newBlock); + } + else if (containingStatement.Parent is GlobalStatementSyntax globalStatement && + globalStatement.Parent is CompilationUnitSyntax compilationUnitFromGlobal) + { + var newCompilationUnit = compilationUnitFromGlobal.InsertNodesAfter( + globalStatement, + [SyntaxFactory.GlobalStatement(addDaprWorkflowStatement)]); - var variableDeclarator = createBuilderInvocation?.Ancestors() - .OfType() - .FirstOrDefault(); + targetRoot = targetRoot.ReplaceNode(compilationUnitFromGlobal, newCompilationUnit); + } + else if (containingStatement.Parent is CompilationUnitSyntax compilationUnit) + { + var newCompilationUnit = compilationUnit.InsertNodesAfter( + containingStatement, + [SyntaxFactory.GlobalStatement(addDaprWorkflowStatement)]); + + targetRoot = targetRoot.ReplaceNode(compilationUnit, newCompilationUnit); + } + else + { + return (null, null); + } - var builderVariable = variableDeclarator?.Identifier.Text; + var addDaprWorkflowInvocation = targetRoot.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddDaprWorkflow"); - if (createBuilderInvocation == null) - { - return (null, null); + return (document, addDaprWorkflowInvocation); } - var targetRoot = await createBuilderInvocation.SyntaxTree.GetRootAsync(cancellationToken); - var document = project.GetDocument(createBuilderInvocation.SyntaxTree); - - if (createBuilderInvocation.Expression is not MemberAccessExpressionSyntax { Expression: IdentifierNameSyntax }) + // Case 3 : Host.CreateDefaultBuilder(args).ConfigureServices(services => {...}); + var configureServicesInvocation = await FindConfigureServicesInvocationAsync(project, cancellationToken); + if (configureServicesInvocation != null) { - return (null, null); - } + var document = project.GetDocument(configureServicesInvocation.SyntaxTree); + var targetRoot = await configureServicesInvocation.SyntaxTree.GetRootAsync(cancellationToken); + if (targetRoot == null || document == null) + return (null, null); - var addDaprWorkflowStatement = SyntaxFactory.ParseStatement($"{builderVariable}.Services.AddDaprWorkflow(options => {{ }});"); + var lambda = configureServicesInvocation.ArgumentList.Arguments + .Select(a => a.Expression) + .OfType() + .FirstOrDefault(); - if (createBuilderInvocation.Ancestors().OfType().FirstOrDefault() is SyntaxNode parentBlock) - { - var firstChild = parentBlock.ChildNodes().FirstOrDefault(node => node is not UsingDirectiveSyntax); - var newParentBlock = parentBlock.InsertNodesAfter(firstChild, new[] { addDaprWorkflowStatement }); - targetRoot = targetRoot.ReplaceNode(parentBlock, newParentBlock); - } - else - { - var compilationUnitSyntax = createBuilderInvocation.Ancestors().OfType().FirstOrDefault(); - if (compilationUnitSyntax != null) + if (lambda is null) + return (null, null); + + var servicesParamName = + lambda switch + { + SimpleLambdaExpressionSyntax s => s.Parameter.Identifier.Text, + ParenthesizedLambdaExpressionSyntax p => p.ParameterList.Parameters.LastOrDefault()?.Identifier.Text, + _ => null + }; + + if (string.IsNullOrWhiteSpace(servicesParamName)) + return (null, null); + + var addDaprWorkflowStatement = SyntaxFactory.ParseStatement( + $"{servicesParamName}.AddDaprWorkflow(options =>\n{{\n}});\n"); + + SyntaxNode newRoot = targetRoot; + + if (lambda.Body is BlockSyntax bodyBlock) + { + var newBodyBlock = bodyBlock.WithStatements(bodyBlock.Statements.Insert(0, addDaprWorkflowStatement)); + + LambdaExpressionSyntax? newLambda = + lambda switch + { + SimpleLambdaExpressionSyntax s => s.WithBody(newBodyBlock), + ParenthesizedLambdaExpressionSyntax p => p.WithBody(newBodyBlock), + _ => null + }; + + if (newLambda is null) + return (null, null); + + newRoot = newRoot.ReplaceNode((SyntaxNode)lambda, (SyntaxNode)newLambda); + } + else if (lambda.Body is ExpressionSyntax exprBody) { - var firstChild = compilationUnitSyntax.ChildNodes().FirstOrDefault(node => node is not UsingDirectiveSyntax); - var globalStatement = SyntaxFactory.GlobalStatement(addDaprWorkflowStatement); - var newCompilationUnitSyntax = compilationUnitSyntax.InsertNodesAfter(firstChild, [globalStatement]); - targetRoot = targetRoot.ReplaceNode(compilationUnitSyntax, newCompilationUnitSyntax); + var newBodyBlock = SyntaxFactory.Block( + addDaprWorkflowStatement, + SyntaxFactory.ExpressionStatement(exprBody)); + + LambdaExpressionSyntax? newLambda = + lambda switch + { + SimpleLambdaExpressionSyntax s => s.WithBody(newBodyBlock), + ParenthesizedLambdaExpressionSyntax p => p.WithBody(newBodyBlock), + _ => null + }; + + if (newLambda is null) + return (null, null); + + newRoot = newRoot.ReplaceNode((SyntaxNode)lambda, (SyntaxNode)newLambda); + } + else + { + return (null, null); } - } - var addDaprWorkflowInvocation = targetRoot?.DescendantNodes() - .OfType() - .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && - memberAccess.Name.Identifier.Text == "AddDaprWorkflow"); + newRoot = Formatter.Format(newRoot, document.Project.Solution.Workspace); + + var updatedDoc = document.WithSyntaxRoot(newRoot); + var updatedRoot = await updatedDoc.GetSyntaxRootAsync(cancellationToken); + var addDaprWorkflowInvocation = updatedRoot?.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax memberAccess && + memberAccess.Name.Identifier.Text == "AddDaprWorkflow"); - return (document, addDaprWorkflowInvocation); + return (updatedDoc, addDaprWorkflowInvocation); + } + return (null, null); } private static async Task FindCreateBuilderInvocationAsync(Project project, CancellationToken cancellationToken) @@ -219,16 +317,16 @@ private async Task RegisterWorkflowAsync(Document document, Diagnostic { var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken); - // Find the invocation expression for WebApplication.CreateBuilder() var createBuilderInvocation = syntaxRoot.DescendantNodes() .OfType() .FirstOrDefault(invocation => invocation.Expression is MemberAccessExpressionSyntax { - Expression: IdentifierNameSyntax - { - Identifier.Text: "WebApplication" - }, + Expression: IdentifierNameSyntax { Identifier.Text: "WebApplication" }, Name.Identifier.Text: "CreateBuilder" + } or MemberAccessExpressionSyntax + { + Expression: IdentifierNameSyntax { Identifier.Text: "Host" }, + Name.Identifier.Text: "CreateApplicationBuilder" }); if (createBuilderInvocation != null) @@ -239,4 +337,26 @@ private async Task RegisterWorkflowAsync(Document document, Diagnostic return null; } + + private static async Task FindConfigureServicesInvocationAsync(Project project, CancellationToken cancellationToken) + { + var compilation = await project.GetCompilationAsync(cancellationToken); + + foreach (var syntaxTree in compilation!.SyntaxTrees) + { + var root = await syntaxTree.GetRootAsync(cancellationToken); + + var configureServicesInvocation = root.DescendantNodes() + .OfType() + .FirstOrDefault(invocation => + invocation.Expression is MemberAccessExpressionSyntax { Name.Identifier.Text: "ConfigureServices" }); + + if (configureServicesInvocation != null) + { + return configureServicesInvocation; + } + } + + return null; + } } diff --git a/src/Dapr.Workflow.Grpc/AssemblyInfo.cs b/src/Dapr.Workflow.Grpc/AssemblyInfo.cs new file mode 100644 index 000000000..932a397b8 --- /dev/null +++ b/src/Dapr.Workflow.Grpc/AssemblyInfo.cs @@ -0,0 +1,16 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Dapr.Workflow, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] diff --git a/src/Dapr.Workflow.Grpc/Dapr.Workflow.Grpc.csproj b/src/Dapr.Workflow.Grpc/Dapr.Workflow.Grpc.csproj new file mode 100644 index 000000000..bfd5b76f2 --- /dev/null +++ b/src/Dapr.Workflow.Grpc/Dapr.Workflow.Grpc.csproj @@ -0,0 +1,31 @@ + + + + enable + enable + Dapr.Workflow.Grpc + Dapr Workflow gRPC protocol definitions + The gRPC protocol definitions for Dapr Workflows. + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + + + + diff --git a/src/Dapr.Workflow.Grpc/Extensions/GrpcClientServiceCollectionExtensions.cs b/src/Dapr.Workflow.Grpc/Extensions/GrpcClientServiceCollectionExtensions.cs new file mode 100644 index 000000000..77c9410b8 --- /dev/null +++ b/src/Dapr.Workflow.Grpc/Extensions/GrpcClientServiceCollectionExtensions.cs @@ -0,0 +1,84 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.DurableTask.Protobuf; +using Grpc.Net.ClientFactory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.Workflow.Grpc.Extensions; + +/// +/// Extension methods for registering Dapr Workflow gRPC clients with . +/// +internal static class GrpcClientServiceCollectionExtensions +{ + /// + /// Used to add the gRPC client used internally for communication with the Dapr sidecar by the Dapr Workflow SDK + /// + internal static IHttpClientBuilder AddDaprWorkflowGrpcClient( + this IServiceCollection services, + Action? configureClient = null) => + services.AddGrpcClient((serviceProvider, options) => + { + // Get configuration from DI to support test scenarios with dynamic ports + var configuration = serviceProvider.GetService(); + + // Use DaprDefaults for consistent sidecar address resolution + options.Address = new Uri(DaprDefaults.GetDefaultGrpcEndpoint(configuration)); + + // Configure for long-lived streaming connections + options.ChannelOptionsActions.Add(channelOptions => + { + // Disable idle timeout - connection should never timeout due to inactivity + channelOptions.HttpHandler = new SocketsHttpHandler + { + // Disable all timeouts for long-lived connections + PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan, + KeepAlivePingDelay = TimeSpan.FromSeconds(60), + KeepAlivePingTimeout = TimeSpan.FromSeconds(30), + EnableMultipleHttp2Connections = true, + + // Ensure connections are kept alive + KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always + }; + + // Configure channel-level settings for resilience + channelOptions.MaxReceiveMessageSize = null; // No size limit + channelOptions.MaxSendMessageSize = null; // No size limit + }); + + // Allow consumer to override + configureClient?.Invoke(options); + }) + .ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler + { + // Infinite timeouts - connection should never timeout + ConnectTimeout = Timeout.InfiniteTimeSpan, + PooledConnectionIdleTimeout = Timeout.InfiniteTimeSpan, + PooledConnectionLifetime = Timeout.InfiniteTimeSpan, + + // HTTP/2 keep-alive settings for long-lived connections + KeepAlivePingDelay = TimeSpan.FromSeconds(60), + KeepAlivePingTimeout = TimeSpan.FromSeconds(30), + KeepAlivePingPolicy = HttpKeepAlivePingPolicy.Always, + + // Enable multiple HTTP/2 connections for better throughput + EnableMultipleHttp2Connections = true, + }) + .ConfigureHttpClient(httpClient => + { + // Disable HttpClient's own timeout - let gRPC handle it + httpClient.Timeout = Timeout.InfiniteTimeSpan; + }); +} diff --git a/src/Dapr.Workflow.Grpc/orchestrator_service.proto b/src/Dapr.Workflow.Grpc/orchestrator_service.proto new file mode 100644 index 000000000..f6d08e5e2 --- /dev/null +++ b/src/Dapr.Workflow.Grpc/orchestrator_service.proto @@ -0,0 +1,907 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +syntax = "proto3"; + +option csharp_namespace = "Dapr.DurableTask.Protobuf"; +option java_package = "io.dapr.durabletask.implementation.protobuf"; +option go_package = "/api/protos"; + +import "google/protobuf/timestamp.proto"; +import "google/protobuf/duration.proto"; +import "google/protobuf/wrappers.proto"; +import "google/protobuf/empty.proto"; + +enum StalledReason { + PATCH_MISMATCH = 0; + VERSION_NAME_MISMATCH = 1; +} + +message TaskRouter { + string sourceAppID = 1; + optional string targetAppID = 2; +} + +message OrchestrationVersion { + repeated string patches = 1; + + // The name of the executed workflow + optional string name = 2; +} + +message OrchestrationInstance { + string instanceId = 1; + google.protobuf.StringValue executionId = 2; +} + +message ActivityRequest { + string name = 1; + google.protobuf.StringValue version = 2; + google.protobuf.StringValue input = 3; + OrchestrationInstance orchestrationInstance = 4; + int32 taskId = 5; + TraceContext parentTraceContext = 6; + string taskExecutionId = 7; +} + +message ActivityResponse { + string instanceId = 1; + int32 taskId = 2; + google.protobuf.StringValue result = 3; + TaskFailureDetails failureDetails = 4; + string completionToken = 5; +} + +message TaskFailureDetails { + string errorType = 1; + string errorMessage = 2; + google.protobuf.StringValue stackTrace = 3; + TaskFailureDetails innerFailure = 4; + bool isNonRetriable = 5; +} + +enum OrchestrationStatus { + ORCHESTRATION_STATUS_RUNNING = 0; + ORCHESTRATION_STATUS_COMPLETED = 1; + ORCHESTRATION_STATUS_CONTINUED_AS_NEW = 2; + ORCHESTRATION_STATUS_FAILED = 3; + ORCHESTRATION_STATUS_CANCELED = 4; + ORCHESTRATION_STATUS_TERMINATED = 5; + ORCHESTRATION_STATUS_PENDING = 6; + ORCHESTRATION_STATUS_SUSPENDED = 7; + ORCHESTRATION_STATUS_STALLED = 8; +} + +message ParentInstanceInfo { + int32 taskScheduledId = 1; + google.protobuf.StringValue name = 2; + google.protobuf.StringValue version = 3; + OrchestrationInstance orchestrationInstance = 4; + optional string appID = 5; +} + +// RerunParentInstanceInfo is used to indicate that this orchestration was +// started as part of a rerun operation. Contains information about the parent +// orchestration instance which was rerun. +message RerunParentInstanceInfo { + // instanceID is the orchestration instance ID this orchestration has been + // rerun from. + string instanceID = 1; +} + +message TraceContext { + string traceParent = 1; + string spanID = 2 [deprecated=true]; + google.protobuf.StringValue traceState = 3; +} + +message ExecutionStartedEvent { + string name = 1; + google.protobuf.StringValue version = 2; + google.protobuf.StringValue input = 3; + OrchestrationInstance orchestrationInstance = 4; + ParentInstanceInfo parentInstance = 5; + google.protobuf.Timestamp scheduledStartTimestamp = 6; + TraceContext parentTraceContext = 7; + google.protobuf.StringValue orchestrationSpanID = 8; + map tags = 9; +} + +message ExecutionCompletedEvent { + OrchestrationStatus orchestrationStatus = 1; + google.protobuf.StringValue result = 2; + TaskFailureDetails failureDetails = 3; +} + +message ExecutionTerminatedEvent { + google.protobuf.StringValue input = 1; + bool recurse = 2; +} + +message TaskScheduledEvent { + string name = 1; + google.protobuf.StringValue version = 2; + google.protobuf.StringValue input = 3; + TraceContext parentTraceContext = 4; + string taskExecutionId = 5; + + // If defined, indicates that this task was the starting point of a new + // workflow execution as the result of a rerun operation. + optional RerunParentInstanceInfo rerunParentInstanceInfo = 6; +} + +message TaskCompletedEvent { + int32 taskScheduledId = 1; + google.protobuf.StringValue result = 2; + string taskExecutionId = 3; +} + +message TaskFailedEvent { + int32 taskScheduledId = 1; + TaskFailureDetails failureDetails = 2; + string taskExecutionId = 3; +} + +message SubOrchestrationInstanceCreatedEvent { + string instanceId = 1; + string name = 2; + google.protobuf.StringValue version = 3; + google.protobuf.StringValue input = 4; + TraceContext parentTraceContext = 5; +} + +message SubOrchestrationInstanceCompletedEvent { + int32 taskScheduledId = 1; + google.protobuf.StringValue result = 2; +} + +message SubOrchestrationInstanceFailedEvent { + int32 taskScheduledId = 1; + TaskFailureDetails failureDetails = 2; +} + +message TimerCreatedEvent { + google.protobuf.Timestamp fireAt = 1; + optional string name = 2; + + // If defined, indicates that this task was the starting point of a new + // workflow execution as the result of a rerun operation. + optional RerunParentInstanceInfo rerunParentInstanceInfo = 3; +} + +message TimerFiredEvent { + google.protobuf.Timestamp fireAt = 1; + int32 timerId = 2; +} + +message OrchestratorStartedEvent { + optional OrchestrationVersion version = 1; +} + +message OrchestratorCompletedEvent { + // No payload data +} + +message EventSentEvent { + string instanceId = 1; + string name = 2; + google.protobuf.StringValue input = 3; +} + +message EventRaisedEvent { + string name = 1; + google.protobuf.StringValue input = 2; +} + +message GenericEvent { + google.protobuf.StringValue data = 1; +} + +message HistoryStateEvent { + OrchestrationState orchestrationState = 1; +} + +message ContinueAsNewEvent { + google.protobuf.StringValue input = 1; +} + +message ExecutionSuspendedEvent { + google.protobuf.StringValue input = 1; +} + +message ExecutionResumedEvent { + google.protobuf.StringValue input = 1; +} + +message ExecutionStalledEvent { + StalledReason reason = 1; + optional string description = 2; +} + +message EntityOperationSignaledEvent { + string requestId = 1; + string operation = 2; + google.protobuf.Timestamp scheduledTime = 3; + google.protobuf.StringValue input = 4; + google.protobuf.StringValue targetInstanceId = 5; // used only within histories, null in messages +} + +message EntityOperationCalledEvent { + string requestId = 1; + string operation = 2; + google.protobuf.Timestamp scheduledTime = 3; + google.protobuf.StringValue input = 4; + google.protobuf.StringValue parentInstanceId = 5; // used only within messages, null in histories + google.protobuf.StringValue parentExecutionId = 6; // used only within messages, null in histories + google.protobuf.StringValue targetInstanceId = 7; // used only within histories, null in messages +} + +message EntityLockRequestedEvent { + string criticalSectionId = 1; + repeated string lockSet = 2; + int32 position = 3; + google.protobuf.StringValue parentInstanceId = 4; // used only within messages, null in histories +} + +message EntityOperationCompletedEvent { + string requestId = 1; + google.protobuf.StringValue output = 2; +} + +message EntityOperationFailedEvent { + string requestId = 1; + TaskFailureDetails failureDetails = 2; +} + +message EntityUnlockSentEvent { + string criticalSectionId = 1; + google.protobuf.StringValue parentInstanceId = 2; // used only within messages, null in histories + google.protobuf.StringValue targetInstanceId = 3; // used only within histories, null in messages +} + +message EntityLockGrantedEvent { + string criticalSectionId = 1; +} + +message HistoryEvent { + int32 eventId = 1; + google.protobuf.Timestamp timestamp = 2; + oneof eventType { + ExecutionStartedEvent executionStarted = 3; + ExecutionCompletedEvent executionCompleted = 4; + ExecutionTerminatedEvent executionTerminated = 5; + TaskScheduledEvent taskScheduled = 6; + TaskCompletedEvent taskCompleted = 7; + TaskFailedEvent taskFailed = 8; + SubOrchestrationInstanceCreatedEvent subOrchestrationInstanceCreated = 9; + SubOrchestrationInstanceCompletedEvent subOrchestrationInstanceCompleted = 10; + SubOrchestrationInstanceFailedEvent subOrchestrationInstanceFailed = 11; + TimerCreatedEvent timerCreated = 12; + TimerFiredEvent timerFired = 13; + OrchestratorStartedEvent orchestratorStarted = 14; + OrchestratorCompletedEvent orchestratorCompleted = 15; + EventSentEvent eventSent = 16; + EventRaisedEvent eventRaised = 17; + GenericEvent genericEvent = 18; + HistoryStateEvent historyState = 19; + ContinueAsNewEvent continueAsNew = 20; + ExecutionSuspendedEvent executionSuspended = 21; + ExecutionResumedEvent executionResumed = 22; + EntityOperationSignaledEvent entityOperationSignaled = 23; + EntityOperationCalledEvent entityOperationCalled = 24; + EntityOperationCompletedEvent entityOperationCompleted = 25; + EntityOperationFailedEvent entityOperationFailed = 26; + EntityLockRequestedEvent entityLockRequested = 27; + EntityLockGrantedEvent entityLockGranted = 28; + EntityUnlockSentEvent entityUnlockSent = 29; + ExecutionStalledEvent executionStalled = 31; + } + optional TaskRouter router = 30; +} + +message ScheduleTaskAction { + string name = 1; + google.protobuf.StringValue version = 2; + google.protobuf.StringValue input = 3; + optional TaskRouter router = 4; + string taskExecutionId = 5; +} + +message CreateSubOrchestrationAction { + string instanceId = 1; + string name = 2; + google.protobuf.StringValue version = 3; + google.protobuf.StringValue input = 4; + optional TaskRouter router = 5; +} + +message CreateTimerAction { + google.protobuf.Timestamp fireAt = 1; + optional string name = 2; +} + +message SendEventAction { + OrchestrationInstance instance = 1; + string name = 2; + google.protobuf.StringValue data = 3; +} + +message CompleteOrchestrationAction { + OrchestrationStatus orchestrationStatus = 1; + google.protobuf.StringValue result = 2; + google.protobuf.StringValue details = 3; + google.protobuf.StringValue newVersion = 4; + repeated HistoryEvent carryoverEvents = 5; + TaskFailureDetails failureDetails = 6; +} + +message TerminateOrchestrationAction { + string instanceId = 1; + google.protobuf.StringValue reason = 2; + bool recurse = 3; +} + +message SendEntityMessageAction { + oneof EntityMessageType { + EntityOperationSignaledEvent entityOperationSignaled = 1; + EntityOperationCalledEvent entityOperationCalled = 2; + EntityLockRequestedEvent entityLockRequested = 3; + EntityUnlockSentEvent entityUnlockSent = 4; + } +} + +message OrchestratorAction { + int32 id = 1; + oneof orchestratorActionType { + ScheduleTaskAction scheduleTask = 2; + CreateSubOrchestrationAction createSubOrchestration = 3; + CreateTimerAction createTimer = 4; + SendEventAction sendEvent = 5; + CompleteOrchestrationAction completeOrchestration = 6; + TerminateOrchestrationAction terminateOrchestration = 7; + SendEntityMessageAction sendEntityMessage = 8; + } + optional TaskRouter router = 9; +} + +message OrchestratorRequest { + string instanceId = 1; + google.protobuf.StringValue executionId = 2; + repeated HistoryEvent pastEvents = 3; + repeated HistoryEvent newEvents = 4; + OrchestratorEntityParameters entityParameters = 5; + bool requiresHistoryStreaming = 6; + optional TaskRouter router = 7; +} + +message OrchestratorResponse { + string instanceId = 1; + repeated OrchestratorAction actions = 2; + google.protobuf.StringValue customStatus = 3; + string completionToken = 4; + + // The number of work item events that were processed by the orchestrator. + // This field is optional. If not set, the service should assume that the orchestrator processed all events. + google.protobuf.Int32Value numEventsProcessed = 5; + + optional OrchestrationVersion version = 6; +} + +message CreateInstanceRequest { + string instanceId = 1; + string name = 2; + google.protobuf.StringValue version = 3; + google.protobuf.StringValue input = 4; + google.protobuf.Timestamp scheduledStartTimestamp = 5; + OrchestrationIdReusePolicy orchestrationIdReusePolicy = 6; + google.protobuf.StringValue executionId = 7; + map tags = 8; + TraceContext parentTraceContext = 9; +} + +message OrchestrationIdReusePolicy { + repeated OrchestrationStatus operationStatus = 1; + CreateOrchestrationAction action = 2; +} + +enum CreateOrchestrationAction { + ERROR = 0; + IGNORE = 1; + TERMINATE = 2; +} + +message CreateInstanceResponse { + string instanceId = 1; +} + +message GetInstanceRequest { + string instanceId = 1; + bool getInputsAndOutputs = 2; +} + +message GetInstanceResponse { + bool exists = 1; + OrchestrationState orchestrationState = 2; +} + +message RewindInstanceRequest { + string instanceId = 1; + google.protobuf.StringValue reason = 2; +} + +message RewindInstanceResponse { + // Empty for now. Using explicit type incase we want to add content later. +} + +message OrchestrationState { + string instanceId = 1; + string name = 2; + google.protobuf.StringValue version = 3; + OrchestrationStatus orchestrationStatus = 4; + google.protobuf.Timestamp scheduledStartTimestamp = 5; + google.protobuf.Timestamp createdTimestamp = 6; + google.protobuf.Timestamp lastUpdatedTimestamp = 7; + google.protobuf.StringValue input = 8; + google.protobuf.StringValue output = 9; + google.protobuf.StringValue customStatus = 10; + TaskFailureDetails failureDetails = 11; + google.protobuf.StringValue executionId = 12; + google.protobuf.Timestamp completedTimestamp = 13; + google.protobuf.StringValue parentInstanceId = 14; + map tags = 15; +} + +message RaiseEventRequest { + string instanceId = 1; + string name = 2; + google.protobuf.StringValue input = 3; +} + +message RaiseEventResponse { + // No payload +} + +message TerminateRequest { + string instanceId = 1; + google.protobuf.StringValue output = 2; + bool recursive = 3; +} + +message TerminateResponse { + // No payload +} + +message SuspendRequest { + string instanceId = 1; + google.protobuf.StringValue reason = 2; +} + +message SuspendResponse { + // No payload +} + +message ResumeRequest { + string instanceId = 1; + google.protobuf.StringValue reason = 2; +} + +message ResumeResponse { + // No payload +} + +message QueryInstancesRequest { + InstanceQuery query = 1; +} + +message InstanceQuery{ + repeated OrchestrationStatus runtimeStatus = 1; + google.protobuf.Timestamp createdTimeFrom = 2; + google.protobuf.Timestamp createdTimeTo = 3; + repeated google.protobuf.StringValue taskHubNames = 4; + int32 maxInstanceCount = 5; + google.protobuf.StringValue continuationToken = 6; + google.protobuf.StringValue instanceIdPrefix = 7; + bool fetchInputsAndOutputs = 8; +} + +message QueryInstancesResponse { + repeated OrchestrationState orchestrationState = 1; + google.protobuf.StringValue continuationToken = 2; +} + +message PurgeInstancesRequest { + oneof request { + string instanceId = 1; + PurgeInstanceFilter purgeInstanceFilter = 2; + } + bool recursive = 3; + + // force will force a purge of a workflow, regardless of its current + // runtime state, or whether an active worker can process it, the backend + // will attempt to delete it anyway. This neccessarily means the purging is + // executed out side of the workflow state machine, and therefore, can lead + // to corrupt state or broken workflow execution. Usage of this should + // _only_ be used when the client knows the workflow is not being currently + // processed. It is highly recommended to avoid using this flag unless + // absolutely necessary. + // Defaults to false. + optional bool force = 4; +} + +message PurgeInstanceFilter { + google.protobuf.Timestamp createdTimeFrom = 1; + google.protobuf.Timestamp createdTimeTo = 2; + repeated OrchestrationStatus runtimeStatus = 3; +} + +message PurgeInstancesResponse { + int32 deletedInstanceCount = 1; + google.protobuf.BoolValue isComplete = 2; +} + +message CreateTaskHubRequest { + bool recreateIfExists = 1; +} + +message CreateTaskHubResponse { + //no playload +} + +message DeleteTaskHubRequest { + //no playload +} + +message DeleteTaskHubResponse { + //no playload +} + +message SignalEntityRequest { + string instanceId = 1; + string name = 2; + google.protobuf.StringValue input = 3; + string requestId = 4; + google.protobuf.Timestamp scheduledTime = 5; +} + +message SignalEntityResponse { + // no payload +} + +message GetEntityRequest { + string instanceId = 1; + bool includeState = 2; +} + +message GetEntityResponse { + bool exists = 1; + EntityMetadata entity = 2; +} + +message EntityQuery { + google.protobuf.StringValue instanceIdStartsWith = 1; + google.protobuf.Timestamp lastModifiedFrom = 2; + google.protobuf.Timestamp lastModifiedTo = 3; + bool includeState = 4; + bool includeTransient = 5; + google.protobuf.Int32Value pageSize = 6; + google.protobuf.StringValue continuationToken = 7; +} + +message QueryEntitiesRequest { + EntityQuery query = 1; +} + +message QueryEntitiesResponse { + repeated EntityMetadata entities = 1; + google.protobuf.StringValue continuationToken = 2; +} + +message EntityMetadata { + string instanceId = 1; + google.protobuf.Timestamp lastModifiedTime = 2; + int32 backlogQueueSize = 3; + google.protobuf.StringValue lockedBy = 4; + google.protobuf.StringValue serializedState = 5; +} + +message CleanEntityStorageRequest { + google.protobuf.StringValue continuationToken = 1; + bool removeEmptyEntities = 2; + bool releaseOrphanedLocks = 3; +} + +message CleanEntityStorageResponse { + google.protobuf.StringValue continuationToken = 1; + int32 emptyEntitiesRemoved = 2; + int32 orphanedLocksReleased = 3; +} + +message OrchestratorEntityParameters { + google.protobuf.Duration entityMessageReorderWindow = 1; +} + +message EntityBatchRequest { + string instanceId = 1; + google.protobuf.StringValue entityState = 2; + repeated OperationRequest operations = 3; +} + +message EntityBatchResult { + repeated OperationResult results = 1; + repeated OperationAction actions = 2; + google.protobuf.StringValue entityState = 3; + TaskFailureDetails failureDetails = 4; + string completionToken = 5; + repeated OperationInfo operationInfos = 6; // used only with DTS +} + +message EntityRequest { + string instanceId = 1; + string executionId = 2; + google.protobuf.StringValue entityState = 3; // null if entity does not exist + repeated HistoryEvent operationRequests = 4; +} + +message OperationRequest { + string operation = 1; + string requestId = 2; + google.protobuf.StringValue input = 3; +} + +message OperationResult { + oneof resultType { + OperationResultSuccess success = 1; + OperationResultFailure failure = 2; + } +} + +message OperationInfo { + string requestId = 1; + OrchestrationInstance responseDestination = 2; // null for signals +} + +message OperationResultSuccess { + google.protobuf.StringValue result = 1; +} + +message OperationResultFailure { + TaskFailureDetails failureDetails = 1; +} + +message OperationAction { + int32 id = 1; + oneof operationActionType { + SendSignalAction sendSignal = 2; + StartNewOrchestrationAction startNewOrchestration = 3; + } +} + +message SendSignalAction { + string instanceId = 1; + string name = 2; + google.protobuf.StringValue input = 3; + google.protobuf.Timestamp scheduledTime = 4; +} + +message StartNewOrchestrationAction { + string instanceId = 1; + string name = 2; + google.protobuf.StringValue version = 3; + google.protobuf.StringValue input = 4; + google.protobuf.Timestamp scheduledTime = 5; +} + +message AbandonActivityTaskRequest { + string completionToken = 1; +} + +message AbandonActivityTaskResponse { + // Empty. +} + +message AbandonOrchestrationTaskRequest { + string completionToken = 1; +} + +message AbandonOrchestrationTaskResponse { + // Empty. +} + +message AbandonEntityTaskRequest { + string completionToken = 1; +} + +message AbandonEntityTaskResponse { + // Empty. +} + +service TaskHubSidecarService { + // Sends a hello request to the sidecar service. + rpc Hello(google.protobuf.Empty) returns (google.protobuf.Empty); + + // Starts a new orchestration instance. + rpc StartInstance(CreateInstanceRequest) returns (CreateInstanceResponse); + + // Gets the status of an existing orchestration instance. + rpc GetInstance(GetInstanceRequest) returns (GetInstanceResponse); + + // Rewinds an orchestration instance to last known good state and replays from there. + rpc RewindInstance(RewindInstanceRequest) returns (RewindInstanceResponse); + + // Waits for an orchestration instance to reach a running or completion state. + rpc WaitForInstanceStart(GetInstanceRequest) returns (GetInstanceResponse); + + // Waits for an orchestration instance to reach a completion state (completed, failed, terminated, etc.). + rpc WaitForInstanceCompletion(GetInstanceRequest) returns (GetInstanceResponse); + + // Raises an event to a running orchestration instance. + rpc RaiseEvent(RaiseEventRequest) returns (RaiseEventResponse); + + // Terminates a running orchestration instance. + rpc TerminateInstance(TerminateRequest) returns (TerminateResponse); + + // Suspends a running orchestration instance. + rpc SuspendInstance(SuspendRequest) returns (SuspendResponse); + + // Resumes a suspended orchestration instance. + rpc ResumeInstance(ResumeRequest) returns (ResumeResponse); + + // rpc DeleteInstance(DeleteInstanceRequest) returns (DeleteInstanceResponse); + + rpc QueryInstances(QueryInstancesRequest) returns (QueryInstancesResponse); + rpc PurgeInstances(PurgeInstancesRequest) returns (PurgeInstancesResponse); + + rpc GetWorkItems(GetWorkItemsRequest) returns (stream WorkItem); + rpc CompleteActivityTask(ActivityResponse) returns (CompleteTaskResponse); + rpc CompleteOrchestratorTask(OrchestratorResponse) returns (CompleteTaskResponse); + rpc CompleteEntityTask(EntityBatchResult) returns (CompleteTaskResponse); + + // Gets the history of an orchestration instance as a stream of events. + rpc StreamInstanceHistory(StreamInstanceHistoryRequest) returns (stream HistoryChunk); + + // Deletes and Creates the necessary resources for the orchestration service and the instance store + rpc CreateTaskHub(CreateTaskHubRequest) returns (CreateTaskHubResponse); + + // Deletes the resources for the orchestration service and optionally the instance store + rpc DeleteTaskHub(DeleteTaskHubRequest) returns (DeleteTaskHubResponse); + + // sends a signal to an entity + rpc SignalEntity(SignalEntityRequest) returns (SignalEntityResponse); + + // get information about a specific entity + rpc GetEntity(GetEntityRequest) returns (GetEntityResponse); + + // query entities + rpc QueryEntities(QueryEntitiesRequest) returns (QueryEntitiesResponse); + + // clean entity storage + rpc CleanEntityStorage(CleanEntityStorageRequest) returns (CleanEntityStorageResponse); + + // Abandons a single work item + rpc AbandonTaskActivityWorkItem(AbandonActivityTaskRequest) returns (AbandonActivityTaskResponse); + + // Abandon an orchestration work item + rpc AbandonTaskOrchestratorWorkItem(AbandonOrchestrationTaskRequest) returns (AbandonOrchestrationTaskResponse); + + // Abandon an entity work item + rpc AbandonTaskEntityWorkItem(AbandonEntityTaskRequest) returns (AbandonEntityTaskResponse); + + // Rerun a Workflow from a specific event ID of a workflow instance. + rpc RerunWorkflowFromEvent(RerunWorkflowFromEventRequest) returns (RerunWorkflowFromEventResponse); + + rpc ListInstanceIDs (ListInstanceIDsRequest) returns (ListInstanceIDsResponse); + rpc GetInstanceHistory (GetInstanceHistoryRequest) returns (GetInstanceHistoryResponse); +} + +message GetWorkItemsRequest { + int32 maxConcurrentOrchestrationWorkItems = 1; + int32 maxConcurrentActivityWorkItems = 2; + int32 maxConcurrentEntityWorkItems = 3; + + repeated WorkerCapability capabilities = 10; +} + +enum WorkerCapability { + WORKER_CAPABILITY_UNSPECIFIED = 0; + + // Indicates that the worker is capable of streaming instance history as a more optimized + // alternative to receiving the full history embedded in the orchestrator work-item. + // When set, the service may return work items without any history events as an optimization. + // It is strongly recommended that all SDKs support this capability. + WORKER_CAPABILITY_HISTORY_STREAMING = 1; +} + +message WorkItem { + oneof request { + OrchestratorRequest orchestratorRequest = 1; + ActivityRequest activityRequest = 2; + EntityBatchRequest entityRequest = 3; // (older) used by orchestration services implementations + HealthPing healthPing = 4; + EntityRequest entityRequestV2 = 5; // (newer) used by backend service implementations + } + string completionToken = 10; +} + +message CompleteTaskResponse { + // No payload +} + +message HealthPing { + // No payload +} + +message StreamInstanceHistoryRequest { + string instanceId = 1; + google.protobuf.StringValue executionId = 2; + + // When set to true, the service may return a more optimized response suitable for workers. + bool forWorkItemProcessing = 3; +} + +message HistoryChunk { + repeated HistoryEvent events = 1; +} + +// RerunWorkflowFromEventRequest is used to rerun a workflow instance from a +// specific event ID. +message RerunWorkflowFromEventRequest { + // sourceInstanceID is the orchestration instance ID to rerun. Can be a top + // level instance, or sub-orchestration instance. + string sourceInstanceID = 1; + + // the event id to start the new workflow instance from. + uint32 eventID = 2; + + // newInstanceID is the new instance ID to use for the new workflow instance. + // If not given, a random instance ID will be given. + optional string newInstanceID = 3; + + // input can optionally given to give the new instance a different input to + // the next Activity event. + google.protobuf.StringValue input = 4; + + // overwrite_input signals that the input to the rerun activity should be + // written with input. This is required because of the incorrect typing of + // inputs being `StringValue` which cannot be optional, and therefore no nil + // value can be signalled or overwritten. + bool overwriteInput = 5; +} + +// RerunWorkflowFromEventResponse is the response to executing +// RerunWorkflowFromEvent. +message RerunWorkflowFromEventResponse { + string newInstanceID = 1; +} + +// ListInstanceIDsRequest is used to list all orchestration instances. +message ListInstanceIDsRequest { + // continuationToken is the continuation token to use for pagination. This + // is the token which the next page should start from. If not given, the + // first page will be returned. + optional string continuationToken = 1; + + // pageSize is the maximum number of instances to return for this page. If + // not given, all instances will be attempted to be returned. + optional uint32 pageSize = 2; +} + +// ListInstanceIDsResponse is the response to executing ListInstanceIDs. +message ListInstanceIDsResponse { + // instanceIds is the list of instance IDs returned. + repeated string instanceIds = 1; + + // continuationToken is the continuation token to use for pagination. If + // there are no more pages, this will be null. + optional string continuationToken = 2; +} + +// GetInstanceHistoryRequest is used to get the full history of an +// orchestration instance. +message GetInstanceHistoryRequest { + string instanceId = 1; +} + +// GetInstanceHistoryResponse is the response to executing GetInstanceHistory. +message GetInstanceHistoryResponse { + repeated HistoryEvent events = 1; +} \ No newline at end of file diff --git a/src/Dapr.Workflow.Grpc/runtime_state.proto b/src/Dapr.Workflow.Grpc/runtime_state.proto new file mode 100644 index 000000000..e47ee0615 --- /dev/null +++ b/src/Dapr.Workflow.Grpc/runtime_state.proto @@ -0,0 +1,57 @@ +/* +Copyright 2025 The Dapr Authors +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +syntax = "proto3"; + +package durabletask.protos.backend.v1; + +option csharp_namespace = "Dapr.DurableTask.Protobuf"; +option java_package = "io.dapr.durabletask.implementation.protobuf"; +option go_package = "/api/protos"; + +import "orchestrator_service.proto"; + +import "google/protobuf/timestamp.proto"; +import "google/protobuf/wrappers.proto"; + +message RuntimeStateStalled { + StalledReason reason = 1; + optional string description = 2; +} + +// OrchestrationRuntimeState holds the current state for an orchestration. +message OrchestrationRuntimeState { + string instanceId = 1; + repeated HistoryEvent newEvents = 2; + repeated HistoryEvent oldEvents = 3; + repeated HistoryEvent pendingTasks = 4; + repeated HistoryEvent pendingTimers = 5; + repeated OrchestrationRuntimeStateMessage pendingMessages = 6; + + ExecutionStartedEvent startEvent = 7; + ExecutionCompletedEvent completedEvent = 8; + google.protobuf.Timestamp createdTime = 9; + google.protobuf.Timestamp lastUpdatedTime = 10; + google.protobuf.Timestamp completedTime = 11; + bool continuedAsNew = 12; + bool isSuspended = 13; + + google.protobuf.StringValue customStatus = 14; + optional RuntimeStateStalled stalled = 15; +} + +// OrchestrationRuntimeStateMessage holds an OrchestratorMessage and the target instance ID. +message OrchestrationRuntimeStateMessage { + HistoryEvent historyEvent = 1; + string TargetInstanceID = 2; +} \ No newline at end of file diff --git a/src/Dapr.Workflow/AssemblyInfo.cs b/src/Dapr.Workflow/AssemblyInfo.cs index bc21f15bf..444ec919c 100644 --- a/src/Dapr.Workflow/AssemblyInfo.cs +++ b/src/Dapr.Workflow/AssemblyInfo.cs @@ -14,3 +14,4 @@ using System.Runtime.CompilerServices; [assembly: InternalsVisibleTo("Dapr.Workflow.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100b1f597635c44597fcecb493e2b1327033b29b1a98ac956a1a538664b68f87d45fbaada0438a15a6265e62864947cc067d8da3a7d93c5eb2fcbb850e396c8684dba74ea477d82a1bbb18932c0efb30b64ff1677f85ae833818707ac8b49ad8062ca01d2c89d8ab1843ae73e8ba9649cd28666b539444dcdee3639f95e2a099bb2")] +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/src/Dapr.Workflow/Client/ProtoConverters.cs b/src/Dapr.Workflow/Client/ProtoConverters.cs new file mode 100644 index 000000000..3a5a65312 --- /dev/null +++ b/src/Dapr.Workflow/Client/ProtoConverters.cs @@ -0,0 +1,55 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Serialization; + +namespace Dapr.Workflow.Client; + +/// +/// Converts between proto messages and domain models. +/// +internal static class ProtoConverters +{ + /// + /// Converts proto to . + /// + public static WorkflowMetadata ToWorkflowMetadata(OrchestrationState state, IWorkflowSerializer serializer) => + new(state.InstanceId, state.Name, ToRuntimeStatus(state.OrchestrationStatus), + state.CreatedTimestamp?.ToDateTime() ?? DateTime.MinValue, + state.LastUpdatedTimestamp?.ToDateTime() ?? DateTime.MinValue, serializer) + { + SerializedInput = string.IsNullOrEmpty(state.Input) ? null : state.Input, + SerializedOutput = string.IsNullOrEmpty(state.Output) ? null : state.Output, + SerializedCustomStatus = string.IsNullOrEmpty(state.CustomStatus) ? null : state.CustomStatus + }; + + /// + /// Converts the proto runtime status enum to . + /// + public static WorkflowRuntimeStatus ToRuntimeStatus(OrchestrationStatus status) + => status switch + { + OrchestrationStatus.Running => WorkflowRuntimeStatus.Running, + OrchestrationStatus.Completed => WorkflowRuntimeStatus.Completed, + OrchestrationStatus.ContinuedAsNew => WorkflowRuntimeStatus.ContinuedAsNew, + OrchestrationStatus.Failed => WorkflowRuntimeStatus.Failed, + OrchestrationStatus.Canceled => WorkflowRuntimeStatus.Canceled, + OrchestrationStatus.Terminated => WorkflowRuntimeStatus.Terminated, + OrchestrationStatus.Pending => WorkflowRuntimeStatus.Pending, + OrchestrationStatus.Suspended => WorkflowRuntimeStatus.Suspended, + OrchestrationStatus.Stalled => WorkflowRuntimeStatus.Stalled, + _ => WorkflowRuntimeStatus.Unknown + }; +} diff --git a/src/Dapr.Workflow/Client/StartWorkflowOptions.cs b/src/Dapr.Workflow/Client/StartWorkflowOptions.cs new file mode 100644 index 000000000..6b80e178c --- /dev/null +++ b/src/Dapr.Workflow/Client/StartWorkflowOptions.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; + +namespace Dapr.Workflow.Client; + +/// +/// Options for starting a new workflow instance. +/// +public sealed class StartWorkflowOptions +{ + /// + /// Gets or sets the instance ID for the workflow. + /// + /// + /// If not specified, a random GUID will be generated. + /// + public string? InstanceId { get; set; } + + /// + /// Gets or sets the scheduled start time for the workflow. + /// + /// + /// If not specified or if a time in the past is specified, the workflow will start immediately. Setting + /// this alue improves throughput when creating many workflows. + /// + public DateTimeOffset? StartAt { get; set; } + + /// + /// Gets or sets the optional identifier of the app on which the workflow should be run. + /// + public string? AppId { get; set; } +} diff --git a/src/Dapr.Workflow/Client/StartWorkflowOptionsBuilder.cs b/src/Dapr.Workflow/Client/StartWorkflowOptionsBuilder.cs new file mode 100644 index 000000000..ee520de40 --- /dev/null +++ b/src/Dapr.Workflow/Client/StartWorkflowOptionsBuilder.cs @@ -0,0 +1,62 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; + +namespace Dapr.Workflow.Client; + +/// +/// Fluent builder for . +/// +public sealed class StartWorkflowOptionsBuilder +{ + private string? _instanceId; + private DateTimeOffset? _startAt; + + /// + /// Sets the instance ID for the workflow. + /// + public StartWorkflowOptionsBuilder WithInstanceId(string instanceId) + { + _instanceId = instanceId; + return this; + } + + /// + /// Schedules the workflow to start at a specific date and time. + /// + public StartWorkflowOptionsBuilder StartAt(DateTimeOffset startAt) + { + _startAt = startAt; + return this; + } + + /// + /// Schedules the workflow to start after a delay. + /// + public StartWorkflowOptionsBuilder StartAfter(TimeSpan delay) + { + _startAt = DateTimeOffset.UtcNow.Add(delay); + return this; + } + + /// + /// Builds the . + /// + public StartWorkflowOptions Build() => new StartWorkflowOptions() { InstanceId = _instanceId, StartAt = _startAt }; + + /// + /// Implicit conversion to . + /// + public static implicit operator StartWorkflowOptions(StartWorkflowOptionsBuilder builder) => builder.Build(); +} diff --git a/src/Dapr.Workflow/Client/WorkflowClient.cs b/src/Dapr.Workflow/Client/WorkflowClient.cs new file mode 100644 index 000000000..91c2fbeb9 --- /dev/null +++ b/src/Dapr.Workflow/Client/WorkflowClient.cs @@ -0,0 +1,187 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Dapr.Workflow.Client; + +/// +/// Abstract base class for workflow client implementations. +/// +internal abstract class WorkflowClient : IAsyncDisposable +{ + /// + /// Schedules a new workflow instance for execution. + /// + /// The name of the workflow to schedule. + /// The optional input to pass to the scheduled workflow instance. This must be a serializable value using System.Text.Json. + /// Options configuring the start of the workflow. + /// Token used to cancel workflow scheduling (only if canceled before it's submitted to the Dapr runtime). + /// + public abstract Task ScheduleNewWorkflowAsync( + string workflowName, + object? input = null, + StartWorkflowOptions? options = null, + CancellationToken cancellation = default); + + /// + /// Gets the metadata for a workflow instance. + /// + /// The identifier of the instance to get the metadata for. + /// Specify true to fetch the workflow instance's inputs, outputs and + /// custom status, or false to omit them. Setting the value to false can help minimize the network + /// bandwidth, serialization, and memory costs associated with fetching the instance metadata. + /// Token used to cancel retrieval of the request for the metadata. + /// + public abstract Task GetWorkflowMetadataAsync( + string instanceId, + bool getInputsAndOutputs = true, + CancellationToken cancellationToken = default); + + /// + /// Waits for a workflow to start running. + /// + /// + /// + /// A "started" workflow instance is any instance not in the state. + /// + /// This method will return a completed task if the workflow has already started running or has already completed. + /// + /// + /// The identifier of the instance to wait for. + /// Specify true to fetch the workflow instance's inputs, outputs and + /// custom status, or false to omit them. Setting the value to false can help minimize the network + /// bandwidth, serialization, and memory costs associated with fetching the instance metadata. + /// Token used to cancel the request to wait for the workflow to start (doesn't impact + /// scheduling of the workflow itself as this has already happened). + /// + public abstract Task WaitForWorkflowStartAsync( + string instanceId, + bool getInputsAndOutputs = true, + CancellationToken cancellationToken = default); + + /// + /// Waits for a workflow to complete and returns a object that contains metadata about + /// the started instance. + /// + /// + /// + /// A "completed" workflow instance is any instance in one of the terminal states. For example, the + /// , , or + /// states. + /// + /// Workflows are long-running and could take hours, days, or months before completing. + /// Workflows can also be eternal, in which case they'll never complete unless terminated. + /// In such cases, this call may block indefinitely, so care must be taken to ensure appropriate timeouts are + /// enforced using the parameter. + /// + /// If a workflow instance is already complete when this method is called, the method will return immediately. + /// + /// + /// + public abstract Task WaitForWorkflowCompletionAsync( + string instanceId, + bool getInputsAndOutputs = true, + CancellationToken cancellationToken = default); + + /// + /// Sends an event notification message to a waiting workflow instance. + /// + /// + /// + /// To handle the event, the target workflow instance must be waiting for an + /// event named using the + /// API. + /// If the target workflow instance is not yet waiting for an event named , + /// then the event will be saved in the workflow instance state and dispatched immediately when the + /// workflow calls . + /// This event saving occurs even if the workflow has canceled its wait operation before the event was received. + /// + /// Workflows can wait for the same event name multiple times, so sending multiple events with the same name is + /// allowed. Each external event received by a workflow will complete just one task returned by the + /// method. + /// + /// Raised events for a completed or non-existent workflow instance will be silently discarded. + /// + /// + /// The ID of the workflow instance that will handle the event. + /// The name of the event. Event names are case-sensitive. + /// The serializable (by System.Text.Json) data payload to include with the event. + /// Token used to cancel enqueueing the event to the backend. This does not abort sending + /// the event once enqueued. + /// A task that completes when the event notification message has been enqueued. + public abstract Task RaiseEventAsync(string instanceId, string eventName, object? eventPayload = null, + CancellationToken cancellationToken = default); + + /// + /// Terminates a running workflow instance and updates its runtime status to + /// . + /// + /// The instance ID of the workflow to terminate. + /// The optional output to set for the terminated workflow instance. + /// A token can that be used to cancel the termination request to the backend. Note + /// that this does not abort the termination of the workflow once the cancellation request has been enqueued. + /// A task that completes when the termination message is enqueued. + public abstract Task TerminateWorkflowAsync(string instanceId, object? output = null, + CancellationToken cancellationToken = default); + + /// + /// Suspends a workflow instance, halting processing of it until + /// is used ot resume the workflow. + /// + /// The instance ID of the workflow to suspend. + /// The optional suspension reason. + /// A token that can be used to cancel the suspension operation. Note that cancelling + /// this token does not resume hte workflow if the suspension is successful. + /// A task that complets when the suspension has been committed to the Dapr runtime. + public abstract Task SuspendWorkflowAsync(string instanceId, string? reason = null, + CancellationToken cancellationToken = default); + + /// + /// Resumes a workflow instance that was suspended via . + /// + /// The instance ID of the workflow to resume. + /// The optional resume reason. + /// A token that can be used to cancel the resume operation. Note that canceling this + /// token does not re-suspend the workflow if the resume is successful. + /// A task that completes when the resume operation has been committed to the Dapr runtime. + public abstract Task ResumeWorkflowAsync(string instanceId, string? reason = null, + CancellationToken cancellationToken = default); + + /// + /// Purges workflow instance state from the workflow state store. + /// + /// + /// + /// This method can be used to permanently delete workflow metadata from the underlying state store, + /// including any stored inputs, outputs, and workflow history records. This is often useful for implementing + /// data retention policies and for keeping storage costs minimal. Only workflow instances in the + /// , , or + /// state can be purged. + /// + /// + /// Purging a workflow purges all the child workflows created by the target. + /// + /// + /// The instance ID of the workflow instance to purge. + /// A token that can be used to cancel the purge operation. + /// Returns a task that complets when the purge operation has completed. The value of this task will + /// be true if the workflow state was found and purged successfully; otherwise it will return + /// false. + public abstract Task PurgeInstanceAsync(string instanceId, CancellationToken cancellationToken = default); + + /// + public abstract ValueTask DisposeAsync(); +} diff --git a/src/Dapr.Workflow/Client/WorkflowGrpcClient.cs b/src/Dapr.Workflow/Client/WorkflowGrpcClient.cs new file mode 100644 index 000000000..a2c2e842c --- /dev/null +++ b/src/Dapr.Workflow/Client/WorkflowGrpcClient.cs @@ -0,0 +1,228 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Workflow.Serialization; +using Grpc.Core; +using grpc = Dapr.DurableTask.Protobuf; +using Microsoft.Extensions.Logging; + +namespace Dapr.Workflow.Client; + +/// +/// The gRPC-based implementation of the Workflow client. +/// +internal sealed class WorkflowGrpcClient(grpc.TaskHubSidecarService.TaskHubSidecarServiceClient grpcClient, ILogger logger, IWorkflowSerializer serializer) : WorkflowClient +{ + /// + public override async Task ScheduleNewWorkflowAsync(string workflowName, object? input = null, StartWorkflowOptions? options = null, + CancellationToken cancellationToken = default) + { + var instanceId = options?.InstanceId ?? Guid.NewGuid().ToString(); + + var request = new grpc.CreateInstanceRequest + { + InstanceId = instanceId, + Name = workflowName, + Input = SerializeToJson(input) + }; + + // Add the scheduled start time if specified + if (options?.StartAt is { } startAt) + { + request.ScheduledStartTimestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTimeOffset(startAt); + } + + var response = await grpcClient.StartInstanceAsync(request, cancellationToken: cancellationToken); + logger.LogScheduleWorkflowSuccess(workflowName, instanceId); + return response.InstanceId; + } + + /// + public override async Task GetWorkflowMetadataAsync(string instanceId, bool getInputsAndOutputs = true, + CancellationToken cancellationToken = default) + { + var request = new grpc.GetInstanceRequest + { + InstanceId = instanceId, + GetInputsAndOutputs = getInputsAndOutputs + }; + + try + { + var response = await grpcClient.GetInstanceAsync(request, cancellationToken: cancellationToken); + + if (!response.Exists) + { + logger.LogGetWorkflowMetadataInstanceNotFound(instanceId); + return null; + } + + return ProtoConverters.ToWorkflowMetadata(response.OrchestrationState, serializer); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound) + { + logger.LogGetWorkflowMetadataInstanceNotFound(ex, instanceId); + return null; + } + } + + /// + public override async Task WaitForWorkflowStartAsync(string instanceId, bool getInputsAndOutputs = true, + CancellationToken cancellationToken = default) + { + // Poll until the workflow status (not Pending) + while (true) + { + var metadata = await GetWorkflowMetadataAsync(instanceId, getInputsAndOutputs, cancellationToken); + + if (metadata is null) + { + var ex = new InvalidOperationException($"Workflow instance '{instanceId}' does not exist"); + logger.LogWaitForStartException(ex, instanceId); + throw ex; + } + + if (metadata.RuntimeStatus != WorkflowRuntimeStatus.Pending) + { + logger.LogWaitForStartCompleted(instanceId, metadata.RuntimeStatus); + return metadata; + } + + await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); + } + } + + /// + public override async Task WaitForWorkflowCompletionAsync(string instanceId, bool getInputsAndOutputs = true, + CancellationToken cancellationToken = default) + { + while (true) + { + var metadata = await GetWorkflowMetadataAsync(instanceId, getInputsAndOutputs, cancellationToken); + + if (metadata is null) + { + var ex = new InvalidOperationException($"Workflow instance '{instanceId}' does not exist"); + logger.LogWaitForCompletionException(ex, instanceId); + throw ex; + } + + if (IsTerminalStatus(metadata.RuntimeStatus)) + { + logger.LogWaitForCompletionCompleted(instanceId, metadata.RuntimeStatus); + return metadata; + } + + await Task.Delay(TimeSpan.FromSeconds(1), cancellationToken); + } + } + + /// + public override async Task RaiseEventAsync(string instanceId, string eventName, object? eventPayload = null, + CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(instanceId); + ArgumentException.ThrowIfNullOrEmpty(eventName); + + var request = new grpc.RaiseEventRequest + { + InstanceId = instanceId, Name = eventName, Input = SerializeToJson(eventPayload) + }; + + await grpcClient.RaiseEventAsync(request, cancellationToken: cancellationToken); + logger.LogRaisedEvent(eventName, instanceId); + } + + /// + public override async Task TerminateWorkflowAsync(string instanceId, object? output = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(instanceId); + var request = new grpc.TerminateRequest + { + InstanceId = instanceId, + Output = SerializeToJson(output), + Recursive = true // Terminate child workflows too + }; + + await grpcClient.TerminateInstanceAsync(request, cancellationToken: cancellationToken); + logger.LogTerminateWorkflow(instanceId); + } + + /// + public override async Task SuspendWorkflowAsync(string instanceId, string? reason = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(instanceId); + + var request = new grpc.SuspendRequest { InstanceId = instanceId, Reason = reason ?? string.Empty }; + + await grpcClient.SuspendInstanceAsync(request, cancellationToken: cancellationToken); + logger.LogSuspendWorkflow(instanceId); + } + + /// + public override async Task ResumeWorkflowAsync(string instanceId, string? reason = null, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(instanceId); + + var request = new grpc.ResumeRequest + { + InstanceId = instanceId, + Reason = reason ?? string.Empty + }; + + await grpcClient.ResumeInstanceAsync(request, cancellationToken: cancellationToken); + logger.LogResumedWorkflow(instanceId); + } + + /// + public override async Task PurgeInstanceAsync(string instanceId, CancellationToken cancellationToken = default) + { + ArgumentException.ThrowIfNullOrEmpty(instanceId); + + var request = new grpc.PurgeInstancesRequest + { + InstanceId = instanceId, + Recursive = true // Purge child workflows too + }; + + var response = await grpcClient.PurgeInstancesAsync(request, cancellationToken: cancellationToken); + var purged = response.DeletedInstanceCount > 0; + + if (purged) + { + logger.LogPurgedWorkflowSuccessfully(instanceId); + } + else + { + logger.LogPurgedWorkflowUnsuccessfully(instanceId); + } + + return purged; + } + + /// + public override ValueTask DisposeAsync() + { + // The gRPC client is managed by IHttpClientFactory, no disposal needed + return ValueTask.CompletedTask; + } + + private string SerializeToJson(object? obj) => obj == null ? string.Empty : serializer.Serialize(obj); + + private static bool IsTerminalStatus(WorkflowRuntimeStatus status) => + status is WorkflowRuntimeStatus.Completed or WorkflowRuntimeStatus.Failed + or WorkflowRuntimeStatus.Terminated; +} diff --git a/src/Dapr.Workflow/Client/WorkflowMetadata.cs b/src/Dapr.Workflow/Client/WorkflowMetadata.cs new file mode 100644 index 000000000..5fa8ba6f3 --- /dev/null +++ b/src/Dapr.Workflow/Client/WorkflowMetadata.cs @@ -0,0 +1,82 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Text.Json; +using Dapr.Workflow.Serialization; + +namespace Dapr.Workflow.Client; + +/// +/// Metadata about a workflow instance. +/// +/// The instance ID of the workflow. +/// The name of the workflow. +/// The runtime status of the workflow. +/// The time when the workflow was created. +/// The time when the workflow last updated. +/// Provides serialization support. +internal sealed record WorkflowMetadata( + string InstanceId, + string Name, + WorkflowRuntimeStatus RuntimeStatus, + DateTime CreatedAt, + DateTime LastUpdatedAt, + IWorkflowSerializer Serializer) +{ + /// + /// Gets the serialized input of the workflow, if available. + /// + public string? SerializedInput { get; init; } + + /// + /// Gets the serialized output of the workflow, if available. + /// + public string? SerializedOutput { get; init; } + + /// + /// Gets the serialized custom status of the workflow, if available. + /// + public string? SerializedCustomStatus { get; init; } + + /// + /// Gets the failure details if the workflow failed. + /// + public WorkflowTaskFailureDetails? FailureDetails { get; init; } + + /// + /// Gets a value indicating whether the workflow instance exists. + /// + public bool Exists => !string.IsNullOrEmpty(InstanceId); + + /// + /// Deserializes the workflow input. + /// + /// The type to deserialize to. + /// The deserialized input, or default if not available. + public T? ReadInputAs() => string.IsNullOrEmpty(SerializedInput) ? default : Serializer.Deserialize(SerializedInput); + + /// + /// Deserializes the workflow output. + /// + /// The type to deserialize to. + /// The deserialized output, or default if not available. + public T? ReadOutputAs() => string.IsNullOrEmpty(SerializedOutput) ? default : Serializer.Deserialize(SerializedOutput); + + /// + /// Deserializes the custom status. + /// + /// The type to deserialize to. + /// The deserialized custom status, or default if not available. + public T? ReadCustomStatusAs() => string.IsNullOrEmpty(SerializedCustomStatus) ? default : Serializer.Deserialize(SerializedCustomStatus); +} diff --git a/src/Dapr.Workflow/Dapr.Workflow.csproj b/src/Dapr.Workflow/Dapr.Workflow.csproj index 45b601a17..d2b7632cc 100644 --- a/src/Dapr.Workflow/Dapr.Workflow.csproj +++ b/src/Dapr.Workflow/Dapr.Workflow.csproj @@ -7,16 +7,15 @@ Dapr Workflow SDK for building workflows as code with Dapr alpha - + - - + + + - + - - - + - + \ No newline at end of file diff --git a/src/Dapr.Workflow/DaprWorkflowClient.cs b/src/Dapr.Workflow/DaprWorkflowClient.cs index 119c84294..d747356ce 100644 --- a/src/Dapr.Workflow/DaprWorkflowClient.cs +++ b/src/Dapr.Workflow/DaprWorkflowClient.cs @@ -1,5 +1,5 @@ // ------------------------------------------------------------------------ -// Copyright 2023 The Dapr Authors +// Copyright 2025 The Dapr Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -10,313 +10,322 @@ // See the License for the specific language governing permissions and // limitations under the License. // ------------------------------------------------------------------------ + using System; using System.Threading; using System.Threading.Tasks; -using Dapr.DurableTask; -using Dapr.DurableTask.Client; +using Dapr.Workflow.Client; namespace Dapr.Workflow; /// -/// Defines client operations for managing Dapr Workflow instances. +/// A client for scheduling and managing Dapr Workflow instances. /// /// -/// This is an alternative to the general purpose Dapr client. It uses a gRPC connection to send -/// commands directly to the workflow engine, bypassing the Dapr API layer. +/// This client provides high-level operations for interacting with workflows running on the Dapr sidecar. +/// It communicates directly via gRPC, bypassing the generate-purpose Dapr HTTP API for improved performance. /// -/// The Durable Task client used to communicate with the Dapr sidecar. -/// Thrown if is null. -public class DaprWorkflowClient(DurableTaskClient innerClient) : IAsyncDisposable +public sealed class DaprWorkflowClient : IDaprWorkflowClient { - readonly DurableTaskClient innerClient = innerClient ?? throw new ArgumentNullException(nameof(innerClient)); + private readonly WorkflowClient _innerClient; + /// + /// Initializes a new instance of the class. + /// + /// The Durable Task client used to communicate with the Dapr sidecar. + /// Thrown if is null. + internal DaprWorkflowClient(WorkflowClient innerClient) + { + _innerClient = innerClient ?? throw new ArgumentNullException(nameof(innerClient)); + } + /// /// Schedules a new workflow instance for execution. /// /// The name of the workflow to schedule. /// - /// The unique ID of the workflow instance to schedule. If not specified, a new GUID value is used. - /// - /// - /// The time when the workflow instance should start executing. If not specified or if a date-time in the past - /// is specified, the workflow instance will be scheduled immediately. - /// If specified with a of , - /// this is interpreted as a local time. - /// Setting this value will cause Dapr to not wait for the Workflow to - /// "start", improving throughput of creating many workflows. + /// The unique ID for the workflow instance. If not specified, a GUID is auto-generated. /// /// - /// The optional input to pass to the scheduled workflow instance. This must be a serializable value. + /// The optional input to pass to the workflow. Must be serializable via System.Text.Json. /// + /// The instance ID of the scheduled workflow. + /// Thrown if is null or empty. public Task ScheduleNewWorkflowAsync( string name, string? instanceId = null, - object? input = null, - DateTime? startTime = null) - { - return ScheduleNewWorkflowAsync(name, instanceId, input, (DateTimeOffset?)startTime); - } - + object? input = null) => + ScheduleNewWorkflowAsync(name, instanceId, input, null, CancellationToken.None); + /// - /// Schedules a new workflow instance for execution. + /// Schedules a new workflow instance for execution at a specified time. /// /// The name of the workflow to schedule. /// - /// The unique ID of the workflow instance to schedule. If not specified, a new GUID value is used. + /// The unique ID for the workflow instance. If not specified, a GUID is auto-generated. /// + /// The optional input to pass to the workflow. /// - /// The time when the workflow instance should start executing. If not specified or if a date-time in the past - /// is specified, the workflow instance will be scheduled immediately. - /// Setting this value will cause Dapr to not wait for the Workflow to - /// "start", improving throughput of creating many workflows. - /// - /// - /// The optional input to pass to the scheduled workflow instance. This must be a serializable value. + /// The time when the workflow should start. If in the past or null, the workflow starts immediately. /// + /// The instance ID of the scheduled workflow. + /// Thrown if is null or empty. + public Task ScheduleNewWorkflowAsync( + string name, + string? instanceId, + object? input, + DateTime? startTime) => + ScheduleNewWorkflowAsync(name, instanceId, input, startTime.HasValue ? new DateTimeOffset(startTime.Value) : null, CancellationToken.None); + + /// + /// Schedules a new workflow instance for execution at a specified time. + /// + /// The name of the workflow to schedule. + /// The unique ID for the workflow instance. Auto-generated if not specified. + /// The optional input to pass to the workflow. + /// The time when the workflow should start. If in the past or null, starts immediately. + /// Token to cancel the scheduling operation. + /// The instance ID of the scheduled workflow. + /// Thrown if is null or empty. public Task ScheduleNewWorkflowAsync( string name, string? instanceId, object? input, - DateTimeOffset? startTime) + DateTimeOffset? startTime, + CancellationToken cancellation = default) { - StartOrchestrationOptions options = new(instanceId, startTime); - return this.innerClient.ScheduleNewOrchestrationInstanceAsync(name, input, options); - } + ArgumentException.ThrowIfNullOrEmpty(name); + var options = new StartWorkflowOptions + { + InstanceId = instanceId, + StartAt = startTime + }; + + return _innerClient.ScheduleNewWorkflowAsync(name, input, options, cancellation); + } + /// - /// Fetches runtime state for the specified workflow instance. + /// Gets the current metadata and state of a workflow instance. /// - /// The unique ID of the workflow instance to fetch. + /// The unique ID of the workflow instance to retrieve. /// - /// Specify true to fetch the workflow instance's inputs, outputs, and custom status, or false to - /// omit them. Defaults to true. + /// true to include serialized inputs, outputs, and custom status; false to omit them. + /// Omitting can reduce network bandwidth and memory usage. /// - public async Task GetWorkflowStateAsync(string instanceId, bool getInputsAndOutputs = true) + /// Token to cancel the retrieval operation. + /// + /// A object, or null if the workflow instance does not exist. + /// + /// Thrown if is null or empty. + public async Task GetWorkflowStateAsync( + string instanceId, + bool getInputsAndOutputs = true, + CancellationToken cancellation = default) { - OrchestrationMetadata? metadata = await this.innerClient.GetInstancesAsync( - instanceId, - getInputsAndOutputs); - return new WorkflowState(metadata); + ArgumentException.ThrowIfNullOrEmpty(instanceId); + var metadata = await _innerClient.GetWorkflowMetadataAsync(instanceId, getInputsAndOutputs, cancellation); + return metadata is null ? null : new WorkflowState(metadata); } - + /// - /// Waits for a workflow to start running and returns a object that contains metadata - /// about the started workflow. + /// Waits for a workflow instance to transition from the pending state to an active state. /// /// - /// - /// A "started" workflow instance is any instance not in the state. - /// - /// This method will return a completed task if the workflow has already started running or has already completed. - /// + /// Returns immediately if the workflow has already started or completed. /// /// The unique ID of the workflow instance to wait for. /// - /// Specify true to fetch the workflow instance's inputs, outputs, and custom status, or false to - /// omit them. Setting this value to false can help minimize the network bandwidth, serialization, and memory costs - /// associated with fetching the instance metadata. + /// true to include serialized inputs, outputs, and custom status; false to omit them. /// - /// A that can be used to cancel the wait operation. + /// Token to cancel the wait operation. /// - /// Returns a record that describes the workflow instance and its execution - /// status. If the specified workflow isn't found, the value will be false. + /// A object describing the workflow state once it has started. /// + /// Thrown if is null or empty. + /// Thrown if the workflow instance does not exist. public async Task WaitForWorkflowStartAsync( string instanceId, bool getInputsAndOutputs = true, CancellationToken cancellation = default) { - OrchestrationMetadata metadata = await this.innerClient.WaitForInstanceStartAsync( - instanceId, - getInputsAndOutputs, - cancellation); + ArgumentException.ThrowIfNullOrEmpty(instanceId); + var metadata = await _innerClient.WaitForWorkflowStartAsync(instanceId, getInputsAndOutputs, cancellation); return new WorkflowState(metadata); } - + /// - /// Waits for a workflow to complete and returns a - /// object that contains metadata about the started instance. + /// Waits for a workflow instance to reach a terminal state (completed, failed, or terminated). /// /// /// - /// A "completed" workflow instance is any instance in one of the terminal states. For example, the - /// , , or - /// states. - /// - /// Workflows are long-running and could take hours, days, or months before completing. - /// Workflows can also be eternal, in which case they'll never complete unless terminated. - /// In such cases, this call may block indefinitely, so care must be taken to ensure appropriate timeouts are - /// enforced using the parameter. - /// - /// If a workflow instance is already complete when this method is called, the method will return immediately. + /// This operation may block indefinitely for eternal workflows. Ensure appropriate timeouts + /// are enforced using the parameter. + /// + /// + /// Returns immediately if the workflow is already in a terminal state. /// /// - /// + /// The unique ID of the workflow instance to wait for. + /// + /// true to include serialized inputs, outputs, and custom status; false to omit them. + /// + /// Token to cancel the wait operation. + /// + /// A object containing the final state of the completed workflow. + /// + /// Thrown if is null or empty. + /// Thrown if the workflow instance does not exist. public async Task WaitForWorkflowCompletionAsync( string instanceId, bool getInputsAndOutputs = true, CancellationToken cancellation = default) { - OrchestrationMetadata metadata = await this.innerClient.WaitForInstanceCompletionAsync( - instanceId, - getInputsAndOutputs, - cancellation); + ArgumentException.ThrowIfNullOrEmpty(instanceId); + + // Wait until the workflow completes... + var metadata = await _innerClient.WaitForWorkflowCompletionAsync(instanceId, false, cancellation); + + // ... and then retrieve the workflow state with the inputs/outputs (if requested) + if (getInputsAndOutputs) + { + metadata = await _innerClient.GetWorkflowMetadataAsync(instanceId, getInputsAndOutputs, cancellation); + } + return new WorkflowState(metadata); } - + /// - /// Terminates a running workflow instance and updates its runtime status to - /// . + /// Raises an external event for a workflow instance. /// /// /// - /// This method internally enqueues a "terminate" message in the task hub. When the task hub worker processes - /// this message, it will update the runtime status of the target instance to - /// . You can use the - /// to wait for the instance to reach - /// the terminated state. + /// The target workflow must be waiting for this event via . + /// If the workflow is not currently waiting, the event is buffered and delivered when the workflow begins waiting. /// /// - /// Terminating a workflow terminates all of the child workflow instances that were created by the target. But it - /// has no effect on any in-flight activity function executions - /// that were started by the terminated instance. Those actions will continue to run - /// without interruption. However, their results will be discarded. - /// - /// At the time of writing, there is no way to terminate an in-flight activity execution. + /// Events for non-existent or completed workflows are silently discarded. /// /// - /// The ID of the workflow instance to terminate. - /// The optional output to set for the terminated workflow instance. - /// - /// The cancellation token. This only cancels enqueueing the termination request to the backend. Does not abort - /// termination of the workflow once enqueued. - /// - /// A task that completes when the terminate message is enqueued. - public Task TerminateWorkflowAsync( + /// The unique ID of the target workflow instance. + /// The name of the event (case-sensitive). + /// The optional event data, must be serializable via System.Text.Json. + /// Token to cancel the event submission operation. + /// A task that completes when the event has been enqueued. + /// Thrown if or is null or empty. + public async Task RaiseEventAsync( string instanceId, - string? output = null, + string eventName, + object? eventPayload = null, CancellationToken cancellation = default) { - TerminateInstanceOptions options = new TerminateInstanceOptions { - Output = output, - Recursive = true, - }; - return this.innerClient.TerminateInstanceAsync(instanceId, options, cancellation); + ArgumentException.ThrowIfNullOrEmpty(instanceId); + ArgumentException.ThrowIfNullOrEmpty(eventName); + await _innerClient.RaiseEventAsync(instanceId, eventName, eventPayload, cancellation); } /// - /// Sends an event notification message to a waiting workflow instance. + /// Terminates a running workflow instance. /// /// /// - /// In order to handle the event, the target workflow instance must be waiting for an - /// event named using the - /// API. - /// If the target workflow instance is not yet waiting for an event named , - /// then the event will be saved in the workflow instance state and dispatched immediately when the - /// workflow calls . - /// This event saving occurs even if the workflow has canceled its wait operation before the event was received. - /// - /// Workflows can wait for the same event name multiple times, so sending multiple events with the same name is - /// allowed. Each external event received by an workflow will complete just one task returned by the - /// method. - /// - /// Raised events for a completed or non-existent workflow instance will be silently discarded. + /// Termination updates the workflow status to . + /// Child workflows are also terminated, but in-flight activities continue to completion. /// /// - /// The ID of the workflow instance that will handle the event. - /// The name of the event. Event names are case-insensitive. - /// The serializable data payload to include with the event. - /// - /// The cancellation token. This only cancels enqueueing the event to the backend. Does not abort sending the event - /// once enqueued. - /// - /// A task that completes when the event notification message has been enqueued. - /// - /// Thrown if or is null or empty. - /// - public Task RaiseEventAsync( + /// The unique ID of the workflow instance to terminate. + /// Optional output to set as the workflow's result. + /// Token to cancel the termination request. + /// A task that completes when the termination has been enqueued. + /// Thrown if is null or empty. + public async Task TerminateWorkflowAsync( string instanceId, - string eventName, - object? eventPayload = null, + object? output = null, CancellationToken cancellation = default) { - return this.innerClient.RaiseEventAsync(instanceId, eventName, eventPayload, cancellation); + ArgumentException.ThrowIfNullOrEmpty(instanceId); + await _innerClient.TerminateWorkflowAsync(instanceId, output, cancellation); } /// - /// Suspends a workflow instance, halting processing of it until - /// is used to resume the workflow. + /// Suspends a workflow instance, pausing its execution until resumed. /// - /// The instance ID of the workflow to suspend. - /// The optional suspension reason. - /// - /// A that can be used to cancel the suspend operation. Note, cancelling this token - /// does not resume the workflow if suspend was successful. - /// - /// A task that completes when the suspend has been committed to the backend. - public Task SuspendWorkflowAsync( + /// The unique ID of the workflow instance to suspend. + /// Optional reason for the suspension. + /// Token to cancel the suspension request. + /// A task that completes when the suspension has been committed. + /// Thrown if is null or empty. + public async Task SuspendWorkflowAsync( string instanceId, string? reason = null, CancellationToken cancellation = default) { - return this.innerClient.SuspendInstanceAsync(instanceId, reason, cancellation); + ArgumentException.ThrowIfNullOrEmpty(instanceId); + await _innerClient.SuspendWorkflowAsync(instanceId, reason, cancellation); } /// - /// Resumes a workflow instance that was suspended via . + /// Resumes a previously suspended workflow instance. /// - /// The instance ID of the workflow to resume. - /// The optional resume reason. - /// - /// A that can be used to cancel the resume operation. Note, cancelling this token - /// does not re-suspend the workflow if resume was successful. - /// - /// A task that completes when the resume has been committed to the backend. - public Task ResumeWorkflowAsync( + /// The unique ID of the workflow instance to resume. + /// Optional reason for the resumption. + /// Token to cancel the resume request. + /// A task that completes when the resumption has been committed. + /// Thrown if is null or empty. + public async Task ResumeWorkflowAsync( string instanceId, string? reason = null, CancellationToken cancellation = default) { - return this.innerClient.ResumeInstanceAsync(instanceId, reason, cancellation); + ArgumentException.ThrowIfNullOrEmpty(instanceId); + await _innerClient.ResumeWorkflowAsync(instanceId, reason, cancellation); } /// - /// Purges workflow instance state from the workflow state store. + /// Permanently deletes a workflow instance from the state store. /// /// /// - /// This method can be used to permanently delete workflow metadata from the underlying state store, - /// including any stored inputs, outputs, and workflow history records. This is often useful for implementing - /// data retention policies and for keeping storage costs minimal. Only workflow instances in the - /// , , or - /// state can be purged. + /// Only workflows in terminal states (, + /// , or ) can be purged. /// /// - /// Purging a workflow purges all of the child workflows that were created by the target. + /// Purging also removes all child workflows and their history records. /// /// /// The unique ID of the workflow instance to purge. - /// - /// A that can be used to cancel the purge operation. - /// + /// Token to cancel the purge operation. /// - /// Returns a task that completes when the purge operation has completed. The value of this task will be - /// true if the workflow state was found and purged successfully; false otherwise. + /// A task that completes when the purge operation finishes. + /// The result is true if successfully purged; false if the workflow doesn't exist or isn't in a terminal state. /// - public async Task PurgeInstanceAsync(string instanceId, CancellationToken cancellation = default) + /// Thrown if is null or empty. + public async Task PurgeInstanceAsync( + string instanceId, + CancellationToken cancellation = default) { - PurgeInstanceOptions options = new PurgeInstanceOptions {Recursive = true}; - PurgeResult result = await this.innerClient.PurgeInstanceAsync(instanceId, options, cancellation); - return result.PurgedInstanceCount > 0; + ArgumentException.ThrowIfNullOrEmpty(instanceId); + return await _innerClient.PurgeInstanceAsync(instanceId, cancellation); } /// /// Disposes any unmanaged resources associated with this client. /// - public ValueTask DisposeAsync() + public async ValueTask DisposeAsync() + { + await _innerClient.DisposeAsync(); + } + + /// + public void Dispose() { - return ((IAsyncDisposable)this.innerClient).DisposeAsync(); + if (_innerClient is IDisposable innerClientDisposable) + { + innerClientDisposable.Dispose(); + } + else + { + _ = _innerClient.DisposeAsync().AsTask(); + } } } diff --git a/src/Dapr.Workflow/DaprWorkflowClientBuilderFactory.cs b/src/Dapr.Workflow/DaprWorkflowClientBuilderFactory.cs deleted file mode 100644 index a6aeacbc4..000000000 --- a/src/Dapr.Workflow/DaprWorkflowClientBuilderFactory.cs +++ /dev/null @@ -1,91 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright 2024 The Dapr Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------ - -using System; -using System.Net.Http; -using Grpc.Net.Client; -using Dapr.DurableTask.Client; -using Dapr.DurableTask.Worker; -using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; - -namespace Dapr.Workflow; - -/// -/// A factory for building a . -/// -internal sealed class DaprWorkflowClientBuilderFactory(IConfiguration? configuration, IHttpClientFactory httpClientFactory) -{ - /// - /// Responsible for building the client itself. - /// - /// - public void CreateClientBuilder(IServiceCollection services, Action configure) - { - services.AddDurableTaskClient(builder => - { - WorkflowRuntimeOptions options = new(); - configure.Invoke(options); - - var apiToken = DaprDefaults.GetDefaultDaprApiToken(configuration); - var grpcEndpoint = DaprDefaults.GetDefaultGrpcEndpoint(configuration); - - var httpClient = httpClientFactory.CreateClient(); - - if (!string.IsNullOrWhiteSpace(apiToken)) - { - httpClient.DefaultRequestHeaders.Add("Dapr-Api-Token", apiToken); - } - - var channelOptions = options.GrpcChannelOptions ?? new GrpcChannelOptions - { - HttpClient = httpClient - }; - - builder.UseGrpc(GrpcChannel.ForAddress(grpcEndpoint, channelOptions)); - builder.RegisterDirectly(); - }); - - services.AddDurableTaskWorker(builder => - { - WorkflowRuntimeOptions options = new(); - configure.Invoke(options); - - var apiToken = DaprDefaults.GetDefaultDaprApiToken(configuration); - var grpcEndpoint = DaprDefaults.GetDefaultGrpcEndpoint(configuration); - - if (!string.IsNullOrEmpty(grpcEndpoint)) - { - var httpClient = httpClientFactory.CreateClient(); - - if (!string.IsNullOrWhiteSpace(apiToken)) - { - httpClient.DefaultRequestHeaders.Add("Dapr-Api-Token", apiToken); - } - - var channelOptions = options.GrpcChannelOptions ?? new GrpcChannelOptions - { - HttpClient = httpClient - }; - - builder.UseGrpc(GrpcChannel.ForAddress(grpcEndpoint, channelOptions)); - } - else - { - builder.UseGrpc(); - } - - builder.AddTasks(registry => options.AddWorkflowsAndActivitiesToRegistry(registry)); - }); - } -} diff --git a/src/Dapr.Workflow/DaprWorkflowContext.cs b/src/Dapr.Workflow/DaprWorkflowContext.cs deleted file mode 100644 index ff1126050..000000000 --- a/src/Dapr.Workflow/DaprWorkflowContext.cs +++ /dev/null @@ -1,143 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright 2023 The Dapr Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------ - -namespace Dapr.Workflow; - -using System; -using Dapr.DurableTask; -using Microsoft.Extensions.Logging; -using System.Threading.Tasks; -using System.Threading; - -class DaprWorkflowContext : WorkflowContext -{ - readonly TaskOrchestrationContext innerContext; - - internal DaprWorkflowContext(TaskOrchestrationContext innerContext) - { - this.innerContext = innerContext ?? throw new ArgumentNullException(nameof(innerContext)); - } - - public override string Name => this.innerContext.Name; - - public override string InstanceId => this.innerContext.InstanceId; - - public override DateTime CurrentUtcDateTime => this.innerContext.CurrentUtcDateTime; - - public override bool IsReplaying => this.innerContext.IsReplaying; - - public override Task CallActivityAsync(string name, object? input = null, WorkflowTaskOptions? options = null) - { - return WrapExceptions(this.innerContext.CallActivityAsync(name, input, options?.ToDurableTaskOptions())); - } - - public override Task CallActivityAsync(string name, object? input = null, WorkflowTaskOptions? options = null) - { - return WrapExceptions(this.innerContext.CallActivityAsync(name, input, options?.ToDurableTaskOptions())); - } - - public override Task CreateTimer(TimeSpan delay, CancellationToken cancellationToken = default) - { - return this.innerContext.CreateTimer(delay, cancellationToken); - } - - public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken) - { - return this.innerContext.CreateTimer(fireAt, cancellationToken); - } - - public override Task WaitForExternalEventAsync(string eventName, CancellationToken cancellationToken = default) - { - return this.innerContext.WaitForExternalEvent(eventName, cancellationToken); - } - - public override Task WaitForExternalEventAsync(string eventName, TimeSpan timeout) - { - return this.innerContext.WaitForExternalEvent(eventName, timeout); - } - - public override void SendEvent(string instanceId, string eventName, object payload) - { - this.innerContext.SendEvent(instanceId, eventName, payload); - } - - public override void SetCustomStatus(object? customStatus) - { - this.innerContext.SetCustomStatus(customStatus); - } - - public override Task CallChildWorkflowAsync(string workflowName, object? input = null, ChildWorkflowTaskOptions? options = null) - { - return WrapExceptions(this.innerContext.CallSubOrchestratorAsync(workflowName, input, options?.ToDurableTaskOptions())); - } - - public override Task CallChildWorkflowAsync(string workflowName, object? input = null, ChildWorkflowTaskOptions? options = null) - { - return WrapExceptions(this.innerContext.CallSubOrchestratorAsync(workflowName, input, options?.ToDurableTaskOptions())); - } - - public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) - { - this.innerContext.ContinueAsNew(newInput!, preserveUnprocessedEvents); - } - - public override Guid NewGuid() - { - return this.innerContext.NewGuid(); - } - - /// - /// Returns an instance of that is replay-safe, meaning that the logger only - /// writes logs when the orchestrator is not replaying previous history. - /// - /// The logger's category name. - /// An instance of that is replay-safe. - public override ILogger CreateReplaySafeLogger(string categoryName) => - this.innerContext.CreateReplaySafeLogger(categoryName); - - /// - /// The type to derive the category name from. - public override ILogger CreateReplaySafeLogger(Type type) => - this.innerContext.CreateReplaySafeLogger(type); - - /// - /// The type to derive category name from. - public override ILogger CreateReplaySafeLogger() => - this.innerContext.CreateReplaySafeLogger(); - - static async Task WrapExceptions(Task task) - { - try - { - await task; - } - catch (TaskFailedException ex) - { - var details = new WorkflowTaskFailureDetails(ex.FailureDetails); - throw new WorkflowTaskFailedException(ex.Message, details); - } - } - - static async Task WrapExceptions(Task task) - { - try - { - return await task; - } - catch (TaskFailedException ex) - { - var details = new WorkflowTaskFailureDetails(ex.FailureDetails); - throw new WorkflowTaskFailedException(ex.Message, details); - } - } -} diff --git a/src/Dapr.Workflow/IDaprWorkflowClient.cs b/src/Dapr.Workflow/IDaprWorkflowClient.cs new file mode 100644 index 000000000..a1369f12f --- /dev/null +++ b/src/Dapr.Workflow/IDaprWorkflowClient.cs @@ -0,0 +1,237 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading; +using System.Threading.Tasks; +using Dapr.Common; +using Dapr.Workflow.Client; + +namespace Dapr.Workflow; + +/// +/// Defines methods for scheduling and managing Dapr Workflow instances. +/// +public interface IDaprWorkflowClient: IDaprClient, IAsyncDisposable +{ + /// + /// Schedules a new workflow instance for execution. + /// + /// The name of the workflow to schedule. + /// + /// The unique ID for the workflow instance. If not specified, a GUID is auto-generated. + /// + /// + /// The optional input to pass to the workflow. Must be serializable via System.Text.Json. + /// + /// The instance ID of the scheduled workflow. + /// Thrown if is null or empty. + Task ScheduleNewWorkflowAsync( + string name, + string? instanceId = null, + object? input = null); + + /// + /// Schedules a new workflow instance for execution at a specified time. + /// + /// The name of the workflow to schedule. + /// + /// The unique ID for the workflow instance. If not specified, a GUID is auto-generated. + /// + /// The optional input to pass to the workflow. + /// + /// The time when the workflow should start. If in the past or null, the workflow starts immediately. + /// + /// The instance ID of the scheduled workflow. + /// Thrown if is null or empty. + Task ScheduleNewWorkflowAsync( + string name, + string? instanceId, + object? input, + DateTime? startTime); + + /// + /// Schedules a new workflow instance for execution at a specified time. + /// + /// The name of the workflow to schedule. + /// The unique ID for the workflow instance. Auto-generated if not specified. + /// The optional input to pass to the workflow. + /// The time when the workflow should start. If in the past or null, starts immediately. + /// Token to cancel the scheduling operation. + /// The instance ID of the scheduled workflow. + /// Thrown if is null or empty. + Task ScheduleNewWorkflowAsync( + string name, + string? instanceId, + object? input, + DateTimeOffset? startTime, + CancellationToken cancellation = default); + + /// + /// Gets the current metadata and state of a workflow instance. + /// + /// The unique ID of the workflow instance to retrieve. + /// + /// true to include serialized inputs, outputs, and custom status; false to omit them. + /// Omitting can reduce network bandwidth and memory usage. + /// + /// Token to cancel the retrieval operation. + /// + /// A object, or null if the workflow instance does not exist. + /// + /// Thrown if is null or empty. + Task GetWorkflowStateAsync( + string instanceId, + bool getInputsAndOutputs = true, + CancellationToken cancellation = default); + + /// + /// Waits for a workflow instance to transition from the pending state to an active state. + /// + /// + /// Returns immediately if the workflow has already started or completed. + /// + /// The unique ID of the workflow instance to wait for. + /// + /// true to include serialized inputs, outputs, and custom status; false to omit them. + /// + /// Token to cancel the wait operation. + /// + /// A object describing the workflow state once it has started. + /// + /// Thrown if is null or empty. + /// Thrown if the workflow instance does not exist. + Task WaitForWorkflowStartAsync( + string instanceId, + bool getInputsAndOutputs = true, + CancellationToken cancellation = default); + + /// + /// Waits for a workflow instance to reach a terminal state (completed, failed, or terminated). + /// + /// + /// + /// This operation may block indefinitely for eternal workflows. Ensure appropriate timeouts + /// are enforced using the parameter. + /// + /// + /// Returns immediately if the workflow is already in a terminal state. + /// + /// + /// The unique ID of the workflow instance to wait for. + /// + /// true to include serialized inputs, outputs, and custom status; false to omit them. + /// + /// Token to cancel the wait operation. + /// + /// A object containing the final state of the completed workflow. + /// + /// Thrown if is null or empty. + /// Thrown if the workflow instance does not exist. + Task WaitForWorkflowCompletionAsync( + string instanceId, + bool getInputsAndOutputs = true, + CancellationToken cancellation = default); + + /// + /// Raises an external event for a workflow instance. + /// + /// + /// + /// The target workflow must be waiting for this event via . + /// If the workflow is not currently waiting, the event is buffered and delivered when the workflow begins waiting. + /// + /// + /// Events for non-existent or completed workflows are silently discarded. + /// + /// + /// The unique ID of the target workflow instance. + /// The name of the event (case-sensitive). + /// The optional event data, must be serializable via System.Text.Json. + /// Token to cancel the event submission operation. + /// A task that completes when the event has been enqueued. + /// Thrown if or is null or empty. + Task RaiseEventAsync( + string instanceId, + string eventName, + object? eventPayload = null, + CancellationToken cancellation = default); + + /// + /// Terminates a running workflow instance. + /// + /// + /// + /// Termination updates the workflow status to . + /// Child workflows are also terminated, but in-flight activities continue to completion. + /// + /// + /// The unique ID of the workflow instance to terminate. + /// Optional output to set as the workflow's result. + /// Token to cancel the termination request. + /// A task that completes when the termination has been enqueued. + /// Thrown if is null or empty. + Task TerminateWorkflowAsync( + string instanceId, + object? output = null, + CancellationToken cancellation = default); + + /// + /// Suspends a workflow instance, pausing its execution until resumed. + /// + /// The unique ID of the workflow instance to suspend. + /// Optional reason for the suspension. + /// Token to cancel the suspension request. + /// A task that completes when the suspension has been committed. + /// Thrown if is null or empty. + Task SuspendWorkflowAsync( + string instanceId, + string? reason = null, + CancellationToken cancellation = default); + + /// + /// Resumes a previously suspended workflow instance. + /// + /// The unique ID of the workflow instance to resume. + /// Optional reason for the resumption. + /// Token to cancel the resume request. + /// A task that completes when the resumption has been committed. + /// Thrown if is null or empty. + Task ResumeWorkflowAsync( + string instanceId, + string? reason = null, + CancellationToken cancellation = default); + + /// + /// Permanently deletes a workflow instance from the state store. + /// + /// + /// + /// Only workflows in terminal states (, + /// , or ) can be purged. + /// + /// + /// Purging also removes all child workflows and their history records. + /// + /// + /// The unique ID of the workflow instance to purge. + /// Token to cancel the purge operation. + /// + /// A task that completes when the purge operation finishes. + /// The result is true if successfully purged; false if the workflow doesn't exist or isn't in a terminal state. + /// + /// Thrown if is null or empty. + Task PurgeInstanceAsync( + string instanceId, + CancellationToken cancellation = default); +} diff --git a/src/Dapr.Workflow/Logging.cs b/src/Dapr.Workflow/Logging.cs new file mode 100644 index 000000000..d4b81684d --- /dev/null +++ b/src/Dapr.Workflow/Logging.cs @@ -0,0 +1,235 @@ +using System; +using Dapr.DurableTask.Protobuf; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Dapr.Workflow; + +internal static partial class Logging +{ + [LoggerMessage(LogLevel.Information, "Starting Dapr Workflow Worker")] + public static partial void LogWorkerWorkflowStart(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "Stopping Dapr Workflow Worker")] + public static partial void LogWorkerWorkflowStop(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "Workflow worker stopped")] + public static partial void LogWorkerWorkflowCanceled(this ILogger logger); + + [LoggerMessage(LogLevel.Error, "Fatal error in workflow worker")] + public static partial void LogWorkerWorkflowError(this ILogger logger, Exception ex); + + [LoggerMessage(LogLevel.Debug, "Executing workflow: Instance='{InstanceId}'")] + public static partial void LogWorkerWorkflowHandleOrchestratorRequestStart(this ILogger logger, string? instanceId); + + [LoggerMessage(LogLevel.Error, "Workflow '{WorkflowName}' not found in registry")] + public static partial void LogWorkerWorkflowHandleOrchestratorRequestNotInRegistry(this ILogger logger, string workflowName); + + [LoggerMessage(LogLevel.Information, "Workflow execution completed: Name='{WorkflowName}', InstanceId='{InstanceId}'")] + public static partial void LogWorkerWorkflowHandleOrchestratorRequestCompleted(this ILogger logger, string workflowName, string instanceId); + + [LoggerMessage(LogLevel.Error, "Error executing workflow instance '{InstanceId}'")] + public static partial void LogWorkerWorkflowHandleOrchestratorRequestFailed(this ILogger logger, Exception ex, string instanceId); + + [LoggerMessage(LogLevel.Debug, "Executing activity: Name='{ActivityName}', Instance='{InstanceId}', TaskId='{TaskId}'")] + public static partial void LogWorkerWorkflowHandleActivityRequestStart(this ILogger logger, string activityName, string? instanceId, int taskId); + + [LoggerMessage(LogLevel.Error, "Activity '{ActivityName}' not found in registry")] + public static partial void LogWorkerWorkflowHandleActivityRequestNotInRegistry(this ILogger logger, string activityName); + + [LoggerMessage(LogLevel.Debug, "Activity execution completed: Name='{ActivityName}', TaskId='{TasKId}'")] + public static partial void LogWorkerWorkflowHandleActivityRequestCompleted(this ILogger logger, string activityName, int taskId); + + [LoggerMessage(LogLevel.Error, "Error executing activity '{ActivityName}' for instance '{InstanceId}'")] + public static partial void LogWorkerWorkflowHandleActivityRequestFailed(this ILogger logger, Exception ex, string activityName, string? instanceId); + + [LoggerMessage(LogLevel.Warning, "Received unknown work item type: '{WorkItemType}'")] + public static partial void LogGrpcProtocolHandlerUnknownWorkItemType(this ILogger logger, WorkItem.RequestOneofCase workItemType); + + [LoggerMessage(LogLevel.Debug, "Processing workflow request: Instance='{InstanceId}', Active={activeCount}")] + public static partial void LogGrpcProtocolHandlerWorkflowProcessorStart(this ILogger logger, string instanceId, int activeCount); + + [LoggerMessage(LogLevel.Information, "Workflow processing canceled: '{InstanceId}'")] + public static partial void LogGrpcProtocolHandlerWorkflowProcessorCanceled(this ILogger logger, string instanceId); + + [LoggerMessage(LogLevel.Error, "Error processing workflow for instance '{InstanceId}'")] + public static partial void LogGrpcProtocolHandlerWorkflowProcessorError(this ILogger logger, Exception ex, string? instanceId); + + [LoggerMessage(LogLevel.Error, "Failed to send workflow failure result for '{InstanceId}'")] + public static partial void LogGrpcProtocolHandlerWorkflowProcessorFailedToSendError(this ILogger logger, Exception ex, string instanceId); + + [LoggerMessage(LogLevel.Debug, "Processing activity request: Instance='{InstanceId}', Activity='{ActivityName}', TaskId='{TaskId}', Active={activeCount}")] + public static partial void LogGrpcProtocolHandlerActivityProcessorStart(this ILogger logger, string instanceId, string activityName, int taskId, int activeCount); + + [LoggerMessage(LogLevel.Information, "Activity processing canceled: '{ActivityName}'")] + public static partial void LogGrpcProtocolHandlerActivityProcessorCanceled(this ILogger logger, string activityName); + + [LoggerMessage(LogLevel.Error, "Error processing activity '{ActivityName}' for instance '{InstanceId}'")] + public static partial void LogGrpcProtocolHandlerActivityProcessorError(this ILogger logger, Exception ex, string activityName, string? instanceId); + + [LoggerMessage(LogLevel.Error, "Failed to send activity failure result for '{ActivityName}'")] + public static partial void LogGrpcProtocolHandlerActivityProcessorFailedToSendError(this ILogger logger, Exception ex, string activityName); + + [LoggerMessage(LogLevel.Information, "Receive loop completed, waiting for {Count} active work items")] + public static partial void LogGrpcProtocolHandlerReceiveLoopCompleted(this ILogger logger, int count); + + [LoggerMessage(LogLevel.Error, "Error in receive loop")] + public static partial void LogGrpcProtocolHandlerReceiveLoopError(this ILogger logger, Exception ex); + + [LoggerMessage(LogLevel.Information, "Workflow worker gRPC stream canceled during shutdown (expected)")] + public static partial void LogGrpcProtocolHandlerReceiveLoopCanceled(this ILogger logger, Exception ex); + + [LoggerMessage(LogLevel.Information, "Disposing gRPC protocol handler")] + public static partial void LogGrpcProtocolHandlerDisposing(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "gRPC protocol handler disposed")] + public static partial void LogGrpcProtocolHandlerDisposed(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "Starting gRPC bidirectional stream with Dapr sidecar")] + public static partial void LogGrpcProtocolHandlerStartStream(this ILogger logger); + + [LoggerMessage(LogLevel.Information, "gRPC stream canceled")] + public static partial void LogGrpcProtocolHandlerStreamCanceled(this ILogger logger); + + [LoggerMessage(LogLevel.Error, "Error in gRPC protocol handler")] + public static partial void LogGrpcProtocolHandlerGenericError(this ILogger logger, Exception ex); + + [LoggerMessage(LogLevel.Debug, "Successfully created activity instance for '{ActivityName}'")] + public static partial void LogCreateActivityInstanceSuccess(this ILogger logger, string activityName); + + [LoggerMessage(LogLevel.Warning, "Activity '{ActivityName}' not found in registry")] + public static partial void LogCreateActivityNotFoundInRegistry(this ILogger logger, string activityName); + + [LoggerMessage(LogLevel.Error, "Failed to create activity instance for '{ActivityName}'")] + public static partial void LogCreateActivityFailure(this ILogger logger, Exception ex, string activityName); + + [LoggerMessage(LogLevel.Debug, "Successfully created workflow instance for '{WorkflowName}'")] + public static partial void LogCreateWorkflowInstanceSuccess(this ILogger logger, string workflowName); + + [LoggerMessage(LogLevel.Warning, "Workflow '{WorkflowName}' not found in registry")] + public static partial void LogCreateWorkflowNotFoundInRegistry(this ILogger logger, string workflowName); + + [LoggerMessage(LogLevel.Error, "Failed to create workflow instance for '{WorkflowName}'")] + public static partial void LogCreateWorkflowFailure(this ILogger logger, Exception ex, string workflowName); + + [LoggerMessage(LogLevel.Debug, "Registered activity '{ActivityName}'")] + public static partial void LogRegisterActivitySuccess(this ILogger logger, string activityName); + + [LoggerMessage(LogLevel.Warning, "Activity '{ActivityName}' is already registered")] + public static partial void LogRegisterActivityAlreadyRegistered(this ILogger logger, string activityName); + + [LoggerMessage(LogLevel.Debug, "Registered workflow '{WorkflowName}'")] + public static partial void LogRegisterWorkflowSuccess(this ILogger logger, string workflowName); + + [LoggerMessage(LogLevel.Warning, "Workflow '{WorkflowName}' is already registered")] + public static partial void LogRegisterWorkflowAlreadyRegistered(this ILogger logger, string workflowName); + + [LoggerMessage(LogLevel.Information, "Scheduled workflow '{WorkflowName}' with instance ID '{InstanceId}'")] + public static partial void LogScheduleWorkflowSuccess(this ILogger logger, string workflowName, string instanceId); + + [LoggerMessage(LogLevel.Error, "Workflow instance '{InstanceId}' not found")] + public static partial void LogGetWorkflowMetadataInstanceNotFound(this ILogger logger, string instanceId); + + [LoggerMessage(LogLevel.Error, "Workflow instance '{InstanceId}' not found")] + public static partial void LogGetWorkflowMetadataInstanceNotFound(this ILogger logger, RpcException ex, string instanceId); + + [LoggerMessage(LogLevel.Error, "Workflow instance '{instanceId}' does not exist")] + public static partial void LogWaitForStartException(this ILogger logger, InvalidOperationException ex, + string instanceId); + + [LoggerMessage(LogLevel.Debug, "Workflow '{InstanceId}' started with status '{Status}'")] + public static partial void LogWaitForStartCompleted(this ILogger logger, string instanceId, WorkflowRuntimeStatus status); + + [LoggerMessage(LogLevel.Error, "Workflow instance '{InstanceId}' does not exist")] + public static partial void LogWaitForCompletionException(this ILogger logger, InvalidOperationException ex, string instanceId); + + [LoggerMessage(LogLevel.Debug, "Workflow '{InstanceId}' completed with status '{Status}'")] + public static partial void LogWaitForCompletionCompleted(this ILogger logger, string instanceId, WorkflowRuntimeStatus status); + + [LoggerMessage(LogLevel.Information, "Raised event '{EventName}' to workflow '{InstanceId}'")] + public static partial void LogRaisedEvent(this ILogger logger, string eventName, string instanceId); + + [LoggerMessage(LogLevel.Information, "Terminated workflow '{InstanceId}'")] + public static partial void LogTerminateWorkflow(this ILogger logger, string instanceId); + + [LoggerMessage(LogLevel.Information, "Suspended workflow '{InstanceId}'")] + public static partial void LogSuspendWorkflow(this ILogger logger, string instanceId); + + [LoggerMessage(LogLevel.Information, "Resumed workflow '{InstanceId}'")] + public static partial void LogResumedWorkflow(this ILogger logger, string instanceId); + + [LoggerMessage(LogLevel.Information, "Purged workflow: '{InstanceId}'")] + public static partial void LogPurgedWorkflowSuccessfully(this ILogger logger, string instanceId); + + [LoggerMessage(LogLevel.Debug, "Workflow '{InstanceId}' was not purged (may not exist or not in terminal state)")] + public static partial void LogPurgedWorkflowUnsuccessfully(this ILogger logger, string instanceId); + + [LoggerMessage(LogLevel.Debug, "Scheduling activity '{ActivityName}' with task ID '{TaskId}' for workflow '{InstanceId}'")] + public static partial void LogSchedulingActivity(this ILogger logger, string activityName, string instanceId, int taskId); + + [LoggerMessage(LogLevel.Debug, "Activity '{ActivityName}' completed from history for workflow '{InstanceId}'")] + public static partial void LogActivityCompletedFromHistory(this ILogger logger, string activityName, string instanceId); + + [LoggerMessage(LogLevel.Debug, "Activity '{ActivityName}' failed from history for workflow '{InstanceId}'")] + public static partial void LogActivityFailedFromHistory(this ILogger logger, string activityName, string instanceId); + + [LoggerMessage(LogLevel.Debug, "Scheduling timer to fire at '{FireAt}' for workflow '{InstanceId}'")] + public static partial void LogSchedulingTimer(this ILogger logger, DateTime fireAt, string instanceId); + + [LoggerMessage(LogLevel.Debug, "Timer fired from history for workflow '{InstanceId}'")] + public static partial void LogTimerFiredFromHistory(this ILogger logger, string instanceId); + + [LoggerMessage(LogLevel.Debug, "Scheduling child workflow '{WorkflowName}' with instance '{ChildInstanceId}' for parent '{ParentInstanceId}'")] + public static partial void LogSchedulingChildWorkflow(this ILogger logger, string workflowName, string childInstanceId, string parentInstanceId); + + [LoggerMessage(LogLevel.Debug, "Child workflow '{WorkflowName}' completed from history for parent '{ParentInstanceId}'")] + public static partial void LogChildWorkflowCompletedFromHistory(this ILogger logger, string workflowName, string parentInstanceId); + + [LoggerMessage(LogLevel.Debug, "Child workflow '{WorkflowName}' failed from history for parent '{ParentInstanceId}'")] + public static partial void LogChildWorkflowFailedFromHistory(this ILogger logger, string workflowName, string parentInstanceId); + + [LoggerMessage(LogLevel.Debug, "Matched PAST history for child workflow taskId={TaskId}")] + public static partial void LogCallChildWorkflowPastHistoryMatch(this ILogger logger, int taskId); + + [LoggerMessage(LogLevel.Debug, "Matched NEW history for child workflow taskId={TaskId}")] + public static partial void LogCallChildWorkflowNewHistoryMatch(this ILogger logger, int taskId); + + [LoggerMessage(LogLevel.Debug, "Child workflow task {TaskId} ({WorkflowName}) is PENDING")] + public static partial void LogCallChildWorkflowPendingMatch(this ILogger logger, int taskId, string workflowName); + + [LoggerMessage(LogLevel.Debug, "Found completion by instance ID for taskId={TaskId}, childInstanceId={ChildInstanceId}")] + public static partial void LogCallChildWorkflowFoundCompletion(this ILogger logger, int taskId, string childInstanceId); + + [LoggerMessage(LogLevel.Debug, "Found running child workflow by instance ID for taskId={TaskId}")] + public static partial void LogCallChildWorkflowFoundRunning(this ILogger logger, int taskId); + + [LoggerMessage(LogLevel.Debug, "No history found - scheduling new child workflow '{WorkflowName}' with taskId={TaskId}, child instance ID={ChildInstanceId}")] + public static partial void LogCallChildWorkflowSchedulingNew(this ILogger logger, string workflowName, int taskId, string childInstanceId); + + [LoggerMessage(LogLevel.Debug, "Matched PAST history for activity taskId={TaskId}")] + public static partial void LogCallActivityPastHistoryMatch(this ILogger logger, int taskId); + + [LoggerMessage(LogLevel.Debug, "Matched NEW history for activity taskId={TaskId}")] + public static partial void LogCallActivityNewHistoryMatch(this ILogger logger, int taskId); + + [LoggerMessage(LogLevel.Debug, "Activity task {TaskId} ({WorkflowName}) is PENDING")] + public static partial void LogCallActivityPendingMatch(this ILogger logger, int taskId, string workflowName); + + [LoggerMessage(LogLevel.Debug, "Timer match: TaskId {TaskId} fired")] + public static partial void LogCreateTimerMatch(this ILogger logger, int taskId); + + [LoggerMessage(LogLevel.Debug, "CreateTimer: Task {TaskId} is pending")] + public static partial void LogCreateTimerPending(this ILogger logger, int taskId); + + [LoggerMessage(LogLevel.Debug, "Initializing new WorkflowOrchestration context for workflow '{WorkflowName}' in instance '{InstanceId}' with {PastEventsCount} past events, {NewEventsCount} new events, {PastMapEntriesCount} past mapped entries, and {NewMapEntriesCount} new mapped entries")] + public static partial void LogWorkflowContextConstructorSetup(this ILogger logger, string workflowName, string instanceId, int pastEventsCount, int newEventsCount, int pastMapEntriesCount, int newMapEntriesCount); + + [LoggerMessage(LogLevel.Debug, "{Mode}: TaskId {TaskId} ({Name}) matched")] + public static partial void LogHandleHistoryMatch(this ILogger logger, string mode, int taskId, string name); + + [LoggerMessage(LogLevel.Debug, "Orchestrator yielded: Instance {InstanceId} is waiting or {ActionCount} scheduled actions to complete. Sequence at {Sequence}")] + public static partial void LogWorkflowWorkerOrchestratorYield(this ILogger logger, string instanceId, int actionCount, int sequence); + + [LoggerMessage(LogLevel.Debug, "Potential stall: Instance {InstanceId} yielded but scheduled 0 new actions and matched 0 history events")] + public static partial void LogWorkflowWorkerOrchestratorStall(this ILogger logger, string instanceId); +} diff --git a/src/Dapr.Workflow/ParallelExtensions.cs b/src/Dapr.Workflow/ParallelExtensions.cs index cc15ac74a..27503b57c 100644 --- a/src/Dapr.Workflow/ParallelExtensions.cs +++ b/src/Dapr.Workflow/ParallelExtensions.cs @@ -15,6 +15,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; namespace Dapr.Workflow; @@ -22,7 +23,7 @@ namespace Dapr.Workflow; /// Extension methods for that provide high-level parallel processing primitives /// with controlled concurrency. /// -public static class ParallelExtensions +public static partial class ParallelExtensions { /// /// Processes a collection of inputs in parallel with controlled concurrency using a streaming execution model. @@ -94,55 +95,80 @@ public static async Task ProcessInParallelAsync( if (inputList.Count == 0) return []; + // Create a logger to help diagnose the issue + var logger = context.CreateReplaySafeLogger(typeof(ParallelExtensions)); + logger.LogDebug("Starting with {InputCount} inputs with max concurrency {MaxConcurrency}", inputList.Count, maxConcurrency); + + // To maintain determinism, we map inputs to their tasks/results + // We will fill this array as tasks complete var results = new TResult[inputList.Count]; - var inFlightTasks = new Dictionary, int>(); // Task -> result index - var inputIndex = 0; - var completedCount = 0; + + // This dictionary tracks active tasks to their original index so we can place results correctly. + var activeTasks = new Dictionary, int>(maxConcurrency); var exceptions = new List(); - - // Start initial batch up to maxConcurrency - while (inputIndex < inputList.Count && inFlightTasks.Count < maxConcurrency) + + // Use an iterator for the input list + int nextInputIndex = 0; + + // Fill the initial window + while (nextInputIndex < inputList.Count && activeTasks.Count < maxConcurrency) { - var task = taskFactory(inputList[inputIndex]); - inFlightTasks[task] = inputIndex; - inputIndex++; + try + { + var task = taskFactory(inputList[nextInputIndex]); + activeTasks.Add(task, nextInputIndex); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + nextInputIndex++; } - - // Process remaining items with streaming execution - while (completedCount < inputList.Count) + + // Sliding window loop + while (activeTasks.Count > 0) { - var completedTask = await Task.WhenAny(inFlightTasks.Keys); - var resultIndex = inFlightTasks[completedTask]; - + // Wait for any task in the active set to complete + var completedTask = await Task.WhenAny(activeTasks.Keys); + + // Retrieve the index to store the result + var completedIndex = activeTasks[completedTask]; + activeTasks.Remove(completedTask); + + // Store result (awaiting it will propagate exceptions, if any) try { - results[resultIndex] = await completedTask; + results[completedIndex] = await completedTask; } catch (Exception ex) { exceptions.Add(ex); } - - inFlightTasks.Remove(completedTask); - completedCount++; - - // Start next task if more work remains - if (inputIndex < inputList.Count) + + // If there are more inputs, schedule the next one immediately + if (nextInputIndex < inputList.Count) { - var nextTask = taskFactory(inputList[inputIndex]); - inFlightTasks[nextTask] = inputIndex; - inputIndex++; + try + { + var newTask = taskFactory(inputList[nextInputIndex]); + activeTasks.Add(newTask, nextInputIndex); + } + catch (Exception ex) + { + exceptions.Add(ex); + } + nextInputIndex++; } } - // If any exceptions occurred, throw them as an aggregate if (exceptions.Count > 0) { - throw new AggregateException( - $"One or more tasks failed during parallel processing. {exceptions.Count} out of {inputList.Count} tasks failed.", - exceptions); + throw new AggregateException($"{exceptions.Count} out of {inputList.Count} tasks failed", exceptions); } + logger.LogDebug("Completed processing {ResultCount} results", results.Length); return results; } + + // Removed partial methods to avoid generator issues in tests } diff --git a/src/Dapr.Workflow/Registration/DaprWorkflowClientBuilder.cs b/src/Dapr.Workflow/Registration/DaprWorkflowClientBuilder.cs new file mode 100644 index 000000000..41df6df30 --- /dev/null +++ b/src/Dapr.Workflow/Registration/DaprWorkflowClientBuilder.cs @@ -0,0 +1,133 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Text.Json; +using Dapr.Common; +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Client; +using Dapr.Workflow.Serialization; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Dapr.Workflow.Registration; + +/// +/// Fluent builder for optional workflow configuration (e.g. serialization registration). +/// +public sealed class DaprWorkflowClientBuilder(IConfiguration? configuration = null) : DaprGenericClientBuilder(configuration) +{ + private IWorkflowSerializer? _serializer; + private IServiceProvider? _serviceProvider; + private Func? _serializerFactory; + + /// + /// Configures a custom workflow serializer to replace the default JSON serializer. + /// + /// The custom serializer instance to use. + /// The instance. + public DaprWorkflowClientBuilder UseSerializer(IWorkflowSerializer serializer) + { + ArgumentNullException.ThrowIfNull(serializer); + _serializer = serializer; + _serializerFactory = null; + return this; + } + + /// + /// Configures a custom workflow serializer using a factory method. + /// + /// A factory function that creates the serializer using the service provider. + /// The instance. + public DaprWorkflowClientBuilder UseSerializer(Func serializerFactory) + { + ArgumentNullException.ThrowIfNull(serializerFactory); + _serializerFactory = serializerFactory; + _serializer = null; + return this; + } + + /// + /// Configures the default System.Text.Json serializer with custom options. + /// + /// The JSON serializer options to use. + /// The instance. + public DaprWorkflowClientBuilder UseJsonSerializer(JsonSerializerOptions jsonOptions) + { + ArgumentNullException.ThrowIfNull(jsonOptions); + return UseSerializer(new JsonWorkflowSerializer(jsonOptions)); + } + + /// + /// Internal method used to set the service provider for factory resolution. + /// + /// The service provider. + internal DaprWorkflowClientBuilder UseServiceProvider(IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + return this; + } + + /// + public override DaprWorkflowClient Build() + { + // Try to get gRPC client from DI first (ensures custom configuration is honored) + TaskHubSidecarService.TaskHubSidecarServiceClient grpcClient; + if (_serviceProvider != null) + { + var diGrpcClient = _serviceProvider.GetService(); + if (diGrpcClient != null) + { + grpcClient = diGrpcClient; + } + else + { + // Fallback: create new gRPC client (not recommended for Workflow) + var (channel, _, _, _) = BuildDaprClientDependencies(typeof(DaprWorkflowClient).Assembly); + grpcClient = new TaskHubSidecarService.TaskHubSidecarServiceClient(channel); + } + } + else + { + // No service provider - create new gRPC client + var (channel, _, _, _) = BuildDaprClientDependencies(typeof(DaprWorkflowClient).Assembly); + grpcClient = new TaskHubSidecarService.TaskHubSidecarServiceClient(channel); + } + + // Resolve serializer + IWorkflowSerializer serializer; + if (_serializer is not null) + { + serializer = _serializer; + } + else + { + serializer = _serializerFactory is not null && _serviceProvider is not null + ? _serializerFactory(_serviceProvider) + : new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + } + + // Resolve logger + ILogger logger = NullLogger.Instance; + var loggerFactory = _serviceProvider?.GetService(); + if (loggerFactory != null) + { + logger = loggerFactory.CreateLogger(); + } + + var innerClient = new WorkflowGrpcClient(grpcClient, logger, serializer); + return new DaprWorkflowClient(innerClient); + } +} diff --git a/src/Dapr.Workflow/Registration/IDaprWorkflowBuilder.cs b/src/Dapr.Workflow/Registration/IDaprWorkflowBuilder.cs new file mode 100644 index 000000000..0e802e163 --- /dev/null +++ b/src/Dapr.Workflow/Registration/IDaprWorkflowBuilder.cs @@ -0,0 +1,21 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Common; + +namespace Dapr.Workflow.Registration; + +/// +/// Responsible for registering Dapr Workflow client functionality. +/// +public interface IDaprWorkflowBuilder : IDaprServiceBuilder; diff --git a/src/Dapr.Workflow/Serialization/IWorkflowSerializer.cs b/src/Dapr.Workflow/Serialization/IWorkflowSerializer.cs new file mode 100644 index 000000000..6015d6d31 --- /dev/null +++ b/src/Dapr.Workflow/Serialization/IWorkflowSerializer.cs @@ -0,0 +1,61 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; + +namespace Dapr.Workflow.Serialization; + +/// +/// Provides serialization and deserialization services for workflow data. +/// +/// +/// Implementations of this interface are responsible for converting objects to and from string representations +/// for transmission between workflows, activities, and the Dapr sidecar. The default implementation uses +/// System.Text.Json, but custom implementations can use any serialization format (Protobuf, MessagePack, XML, etc.). +/// +public interface IWorkflowSerializer +{ + /// + /// Serializes an object to a string representation. + /// + /// The object to serialize. Can be null. + /// + /// Optional type hint for the object being serialized. Some serializers may use this for better type fidelity. + /// If null, the serializer should use the runtime type of . + /// + /// + /// A string representation of the object. Returns an empty string if is null. + /// + string Serialize(object? value, Type? inputType = null); + + /// + /// Deserializes a string to an object of the specified type. + /// + /// The target type to deserialize to. + /// The string data to deserialize. Can be null or empty. + /// + /// The deserialized object of type , or default(T) if is null or empty. + /// + T? Deserialize(string? data); + + /// + /// Deserializes a string to an object of the specified type. + /// + /// The string data to deserialize. Can be null or empty. + /// The target type to deserialize to. + /// + /// The deserialized object, or null if is null or empty. + /// + /// Thrown if is null. + object? Deserialize(string? data, Type returnType); +} diff --git a/src/Dapr.Workflow/Serialization/JsonWorkflowSerializer.cs b/src/Dapr.Workflow/Serialization/JsonWorkflowSerializer.cs new file mode 100644 index 000000000..03c834816 --- /dev/null +++ b/src/Dapr.Workflow/Serialization/JsonWorkflowSerializer.cs @@ -0,0 +1,77 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Text.Json; + +namespace Dapr.Workflow.Serialization; + +/// +/// JSON-based implementation of using System.Text.Json. +/// +public sealed class JsonWorkflowSerializer : IWorkflowSerializer +{ + private readonly JsonSerializerOptions _options; + + /// + /// Initializes a new instance of the class with default JSON options. + /// + /// + /// Uses which provides camelCase naming and other web-friendly defaults. + /// + public JsonWorkflowSerializer() : this(new JsonSerializerOptions(JsonSerializerDefaults.Web)) + { + } + + /// + /// Initializes a new instance of the class with custom JSON options. + /// + /// The JSON serializer options to use for all serialization operations. + /// Thrown if is null. + public JsonWorkflowSerializer(JsonSerializerOptions options) + { + _options = options ?? throw new ArgumentNullException(nameof(options)); + } + + /// + public string Serialize(object? value, Type? inputType = null) + { + if (value is null) + return string.Empty; + + // Use provided type hint for better serialization fidelity + return inputType is not null + ? JsonSerializer.Serialize(value, inputType, _options) + : JsonSerializer.Serialize(value, _options); + } + + /// + public T? Deserialize(string? data) + { + if (string.IsNullOrEmpty(data)) + return default; + + return JsonSerializer.Deserialize(data, _options); + } + + /// + public object? Deserialize(string? data, Type returnType) + { + ArgumentNullException.ThrowIfNull(returnType); + + if (string.IsNullOrEmpty(data)) + return null; + + return JsonSerializer.Deserialize(data, returnType, _options); + } +} diff --git a/src/Dapr.Workflow/Worker/Grpc/GrpcProtocolHandler.cs b/src/Dapr.Workflow/Worker/Grpc/GrpcProtocolHandler.cs new file mode 100644 index 000000000..b29a92699 --- /dev/null +++ b/src/Dapr.Workflow/Worker/Grpc/GrpcProtocolHandler.cs @@ -0,0 +1,279 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Dapr.DurableTask.Protobuf; +using Grpc.Core; +using Microsoft.Extensions.Logging; + +namespace Dapr.Workflow.Worker.Grpc; + +/// +/// Handles the bidirectional gRPC streaming protocol with the Dapr sidecar. +/// +internal sealed class GrpcProtocolHandler(TaskHubSidecarService.TaskHubSidecarServiceClient grpcClient, ILoggerFactory loggerFactory, int maxConcurrentWorkItems = 100, int maxConcurrentActivities = 100) : IAsyncDisposable +{ + private readonly CancellationTokenSource _disposalCts = new(); + private readonly ILogger _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); + private readonly TaskHubSidecarService.TaskHubSidecarServiceClient _grpcClient = + grpcClient ?? throw new ArgumentNullException(nameof(grpcClient)); + private readonly int _maxConcurrentWorkItems = maxConcurrentWorkItems > 0 ? maxConcurrentWorkItems : throw new ArgumentOutOfRangeException(nameof(maxConcurrentWorkItems)); + private readonly int _maxConcurrentActivities = maxConcurrentActivities > 0 ? maxConcurrentActivities : throw new ArgumentOutOfRangeException(nameof(maxConcurrentActivities)); + + private AsyncServerStreamingCall? _streamingCall; + private int _activeWorkItemCount; + + /// + /// Starts the streaming connection with the Dapr sidecar. + /// + /// Handler for workflow work items. + /// Handler for activity work items. + /// Cancellation token. + public async Task StartAsync( + Func> workflowHandler, + Func> activityHandler, + CancellationToken cancellationToken) + { + using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, _disposalCts.Token); + var token = linkedCts.Token; + + try + { + _logger.LogGrpcProtocolHandlerStartStream(); + + // Establish the bidirectional stream + var request = new GetWorkItemsRequest + { + MaxConcurrentOrchestrationWorkItems = _maxConcurrentWorkItems, + MaxConcurrentActivityWorkItems = _maxConcurrentActivities + }; + + // Establish the server streaming call + _streamingCall = _grpcClient.GetWorkItems(request, cancellationToken: token); + + // Process work items from the stream + await ReceiveLoopAsync(_streamingCall.ResponseStream, workflowHandler, activityHandler, token); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled) + { + _logger.LogGrpcProtocolHandlerStreamCanceled(); + } + catch (Exception ex) + { + _logger.LogGrpcProtocolHandlerGenericError(ex); + throw; + } + } + + /// + /// Receives requests from the Dapr sidecar and processes them. + /// + private async Task ReceiveLoopAsync( + IAsyncStreamReader workItemsStream, + Func> orchestratorHandler, + Func> activityHandler, + CancellationToken cancellationToken) + { + // Track active work items for proper exception handling + var activeWorkItems = new List(); + + try + { + await foreach (var workItem in workItemsStream.ReadAllAsync(cancellationToken)) + { + // Dispatch based on work item type + var workItemTask = workItem.RequestCase switch + { + WorkItem.RequestOneofCase.OrchestratorRequest => ProcessWorkflowAsync(workItem.OrchestratorRequest, + orchestratorHandler, cancellationToken), + WorkItem.RequestOneofCase.ActivityRequest => ProcessActivityAsync(workItem.ActivityRequest, + activityHandler, cancellationToken), + _ => Task.Run( + () => _logger.LogGrpcProtocolHandlerUnknownWorkItemType(workItem.RequestCase), + cancellationToken) + }; + + activeWorkItems.Add(workItemTask); + + // Clean up completed tasks periodically + if (activeWorkItems.Count > _maxConcurrentWorkItems * 2) + { + activeWorkItems.RemoveAll(t => t.IsCompleted); + } + } + + _logger.LogGrpcProtocolHandlerReceiveLoopCompleted(activeWorkItems.Count); + + // Wait for all active work items to complete + if (activeWorkItems.Count > 0) + { + await Task.WhenAll(activeWorkItems); + } + } + catch (OperationCanceledException ex) when (cancellationToken.IsCancellationRequested) + { + // Normal shutdown path (host stopping / handler disposing / token canceled) + _logger.LogGrpcProtocolHandlerReceiveLoopCanceled(ex); + } + catch (RpcException ex) when (ex.StatusCode == StatusCode.Cancelled && cancellationToken.IsCancellationRequested) + { + // gRPC surfaces token/dispose cancellation as StatusCode.Cancelled + _logger.LogGrpcProtocolHandlerReceiveLoopCanceled(ex); + } + catch (Exception ex) + { + _logger.LogGrpcProtocolHandlerReceiveLoopError(ex); + throw; + } + } + + /// + /// Processes a workflow request work item. + /// + private async Task ProcessWorkflowAsync(OrchestratorRequest request, + Func> handler, CancellationToken cancellationToken) + { + var activeCount = Interlocked.Increment(ref _activeWorkItemCount); + + try + { + _logger.LogGrpcProtocolHandlerWorkflowProcessorStart(request.InstanceId, activeCount); + + var result = await handler(request); + + // Send the result back to Dapr + await _grpcClient.CompleteOrchestratorTaskAsync(result, cancellationToken: cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogGrpcProtocolHandlerWorkflowProcessorCanceled(request.InstanceId); + } + catch (Exception ex) + { + try + { + var failureResult = CreateWorkflowFailureResult(request, ex); + await _grpcClient.CompleteOrchestratorTaskAsync(failureResult, cancellationToken: cancellationToken); + } + catch (Exception resultEx) + { + _logger.LogGrpcProtocolHandlerWorkflowProcessorFailedToSendError(resultEx, request.InstanceId); + } + } + finally + { + Interlocked.Decrement(ref _activeWorkItemCount); + } + } + + /// + /// Processes an activity request work item. + /// + private async Task ProcessActivityAsync(ActivityRequest request, + Func> handler, CancellationToken cancellationToken) + { + var activeCount = Interlocked.Increment(ref _activeWorkItemCount); + + try + { + _logger.LogGrpcProtocolHandlerActivityProcessorStart(request.OrchestrationInstance.InstanceId, request.Name, + request.TaskId, activeCount); + var result = await handler(request); + + // Send the result back to Dapr + await _grpcClient.CompleteActivityTaskAsync(result, cancellationToken: cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + _logger.LogGrpcProtocolHandlerActivityProcessorCanceled(request.Name); + } + catch (Exception ex) + { + _logger.LogGrpcProtocolHandlerActivityProcessorError(ex, request.Name, + request.OrchestrationInstance?.InstanceId); + + try + { + var failureResult = CreateActivityFailureResult(request, ex); + await _grpcClient.CompleteActivityTaskAsync(failureResult, cancellationToken: cancellationToken); + } + catch (Exception resultEx) + { + _logger.LogGrpcProtocolHandlerActivityProcessorFailedToSendError(resultEx, request.Name); + } + } + finally + { + Interlocked.Decrement(ref _activeWorkItemCount); + } + } + + /// + /// Creates a failure response for an activity exception. + /// + private static ActivityResponse CreateActivityFailureResult(ActivityRequest request, Exception ex) => + new() + { + + InstanceId = request.OrchestrationInstance.InstanceId, + FailureDetails = new() + { + ErrorType = ex.GetType().FullName ?? "Exception", + ErrorMessage = ex.Message, + StackTrace = ex.StackTrace + } + }; + + /// + /// Creates a failure result for an orchestrator exception. + /// + private static OrchestratorResponse CreateWorkflowFailureResult(OrchestratorRequest request, Exception ex) => + new() + { + InstanceId = request.InstanceId, + Actions = + { + new OrchestratorAction + { + CompleteOrchestration = new CompleteOrchestrationAction + { + OrchestrationStatus = OrchestrationStatus.Failed, + FailureDetails = new() + { + ErrorType = ex.GetType().FullName ?? "Exception", + ErrorMessage = ex.Message, + StackTrace = ex.StackTrace + } + } + } + } + }; + + /// + public async ValueTask DisposeAsync() + { + if (_disposalCts.IsCancellationRequested) + return; + + _logger.LogGrpcProtocolHandlerDisposing(); + + await _disposalCts.CancelAsync(); + _streamingCall?.Dispose(); + _disposalCts.Dispose(); + + _logger.LogGrpcProtocolHandlerDisposed(); + } +} diff --git a/src/Dapr.Workflow/Worker/IWorkflowsFactory.cs b/src/Dapr.Workflow/Worker/IWorkflowsFactory.cs new file mode 100644 index 000000000..8ce7d4efd --- /dev/null +++ b/src/Dapr.Workflow/Worker/IWorkflowsFactory.cs @@ -0,0 +1,74 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Threading.Tasks; +using Dapr.Workflow.Abstractions; + +namespace Dapr.Workflow.Worker; + +internal interface IWorkflowsFactory +{ + /// + /// Registers a workflow type. + /// + /// The workflow type to register. + /// Optional workflow name. If not specified, uses the type name. + void RegisterWorkflow(string? name = null) where TWorkflow : class, IWorkflow; + + /// + /// Registers a workflow as a function. + /// + /// The type of the workflow input. + /// The type of the workflow output. + /// Workflow name. + /// Function implementing the workflow definition. + void RegisterWorkflow(string name, + Func> implementation); + + /// + /// Registers a workflow activity type. + /// + /// The activity type to register. + /// Optional activity name. If not specified, uses the type name. + void RegisterActivity(string? name = null) where TActivity : class, IWorkflowActivity; + + /// + /// Registers an activity as a function. + /// + /// The name of the activity. + /// The implementation of the activity. + /// The type of the input to the activity. + /// The type of the output returned from the activity. + public void RegisterActivity(string name, + Func> implementation); + + /// + /// Tries to create a workflow instance. + /// + /// The identifier of the workflow. + /// The service provider for dependency injection. + /// The created workflow, or null if not found. + /// True if the workflow was created; otherwise false. + bool TryCreateWorkflow(TaskIdentifier identifier, IServiceProvider serviceProvider, out IWorkflow? workflow); + + /// + /// Tries to create an activity instance. + /// + /// The identifier of the activity. + /// The service provider for dependency injection. + /// The created activity, or null if not found. + /// True if the activity was created; otherwise false. + bool TryCreateActivity(TaskIdentifier identifier, IServiceProvider serviceProvider, + out IWorkflowActivity? activity); +} diff --git a/src/Dapr.Workflow/Worker/Internal/ReplaySafeLogger.cs b/src/Dapr.Workflow/Worker/Internal/ReplaySafeLogger.cs new file mode 100644 index 000000000..36acfa267 --- /dev/null +++ b/src/Dapr.Workflow/Worker/Internal/ReplaySafeLogger.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using Microsoft.Extensions.Logging; + +namespace Dapr.Workflow.Worker.Internal; + +/// +/// Logger that only logs when not replaying workflow history. +/// +internal sealed class ReplaySafeLogger(ILogger innerLogger, Func isReplaying) : ILogger +{ + private readonly ILogger _innerLogger = innerLogger ?? throw new ArgumentNullException(nameof(innerLogger)); + private readonly Func _isReplaying = isReplaying ?? throw new ArgumentNullException(nameof(isReplaying)); + + public IDisposable? BeginScope(TState state) where TState : notnull => _innerLogger.BeginScope(state); + + public bool IsEnabled(LogLevel logLevel) => !_isReplaying() && _innerLogger.IsEnabled(logLevel); + + public void Log( + LogLevel logLevel, + EventId eventId, + TState state, + Exception? ex, + Func formatter) + { + // Only log if not replaying + if (!_isReplaying()) + { + _innerLogger.Log(logLevel, eventId, state, ex, formatter); + } + } +} diff --git a/src/Dapr.Workflow/DaprWorkflowActivityContext.cs b/src/Dapr.Workflow/Worker/Internal/WorkflowActivityContextImpl.cs similarity index 54% rename from src/Dapr.Workflow/DaprWorkflowActivityContext.cs rename to src/Dapr.Workflow/Worker/Internal/WorkflowActivityContextImpl.cs index 9f7b146bd..05a820e27 100644 --- a/src/Dapr.Workflow/DaprWorkflowActivityContext.cs +++ b/src/Dapr.Workflow/Worker/Internal/WorkflowActivityContextImpl.cs @@ -1,5 +1,5 @@ // ------------------------------------------------------------------------ -// Copyright 2022 The Dapr Authors +// Copyright 2025 The Dapr Authors // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at @@ -11,26 +11,17 @@ // limitations under the License. // ------------------------------------------------------------------------ -namespace Dapr.Workflow; - using System; -using Dapr.DurableTask; +using Dapr.Workflow.Abstractions; + +namespace Dapr.Workflow.Worker.Internal; /// -/// Defines properties and methods for task activity context objects. +/// Implementation of . /// -public class DaprWorkflowActivityContext : WorkflowActivityContext +internal sealed class WorkflowActivityContextImpl(TaskIdentifier identifier, string instanceId, string taskExecutionKey) : WorkflowActivityContext { - readonly TaskActivityContext innerContext; - - internal DaprWorkflowActivityContext(TaskActivityContext innerContext) - { - this.innerContext = innerContext ?? throw new ArgumentNullException(nameof(innerContext)); - } - - /// - public override TaskName Name => this.innerContext.Name; - - /// - public override string InstanceId => this.innerContext.InstanceId; + public override TaskIdentifier Identifier { get; } = identifier; + public override string TaskExecutionKey { get; } = taskExecutionKey; + public override string InstanceId { get; } = instanceId ?? throw new ArgumentNullException(nameof(instanceId)); } diff --git a/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs new file mode 100644 index 000000000..2a27f9bef --- /dev/null +++ b/src/Dapr.Workflow/Worker/Internal/WorkflowOrchestrationContext.cs @@ -0,0 +1,522 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Serialization; +using Microsoft.Extensions.Logging; + +namespace Dapr.Workflow.Worker.Internal; + +/// +/// Internal orchestration context that processes gRPC history events. +/// +/// +/// Here's the intended workflow execution model: +/// First execution: Workflow runs until first `await`, returns pending actions, task doesn't complete +/// Subsequent executions: History is replayed, tasks complete from history, workflow advances further +/// Completion: When no more awaitable operations exist, workflow returns final result +/// +internal sealed class WorkflowOrchestrationContext : WorkflowContext +{ + private readonly List _pastEvents; + private readonly List _newEvents; + private readonly List _pendingActions = []; + private readonly ILogger _logger; + + // Index of events that have already been persisted to the DB + private readonly Dictionary _pastEventMap = new(); + // Index of events that just arrived in this work item + private readonly Dictionary _newEventMap = new(); + // Tracks which external events have been consumed by the workflow code + private readonly HashSet _consumedExternalEvents = new(StringComparer.OrdinalIgnoreCase); + // Parse instance ID as GUID or generate one + private readonly Guid _instanceGuid; + // IDs of tasks that have been scheduled but may not have completed yet + private readonly HashSet _scheduledEventIds = []; + + private int _sequenceNumber; + private int _guidCounter; + private object? _customStatus; + private readonly IWorkflowSerializer workflowSerializer; + + public WorkflowOrchestrationContext(string name, string instanceId, IEnumerable pastEvents, + IEnumerable newEvents, DateTime currentUtcDateTime, IWorkflowSerializer workflowSerializer, + ILoggerFactory loggerFactory) + { + this.workflowSerializer = workflowSerializer; + _logger = loggerFactory.CreateLogger() ?? + throw new ArgumentNullException(nameof(loggerFactory)); + _instanceGuid = Guid.TryParse(instanceId, out var guid) ? guid : Guid.NewGuid(); + Name = name; + InstanceId = instanceId; + CurrentUtcDateTime = currentUtcDateTime; + + _pastEvents = pastEvents.ToList(); + _newEvents = newEvents.ToList(); + + + // 1. Index PAST events + foreach (var e in _pastEvents) + { + if (TryGetTaskScheduledId(e, out int scheduledId)) + { + _pastEventMap[scheduledId] = e; + } + + // Track scheduled/created events to detect "Pending" state + if (e.TaskScheduled != null) _scheduledEventIds.Add(e.EventId); + if (e.SubOrchestrationInstanceCreated != null) _scheduledEventIds.Add(e.EventId); + if (e.TimerCreated != null) _scheduledEventIds.Add(e.EventId); + } + + // 2. Index NEW events + foreach (var e in _newEvents) + { + if (TryGetTaskScheduledId(e, out int scheduledId)) + { + _newEventMap[scheduledId] = e; + } + + // Track scheduled/created events to detect "Pending" state + if (e.TaskScheduled != null) _scheduledEventIds.Add(e.EventId); + if (e.SubOrchestrationInstanceCreated != null) _scheduledEventIds.Add(e.EventId); + if (e.TimerCreated != null) _scheduledEventIds.Add(e.EventId); + } + + _logger.LogWorkflowContextConstructorSetup(name, instanceId, _pastEvents.Count, _newEvents.Count, _pastEventMap.Count, _newEventMap.Count); + } + + /// + public override string Name { get; } + /// + public override string InstanceId { get; } + /// + public override DateTime CurrentUtcDateTime { get; } + + /// + public override bool IsReplaying => _pastEventMap.ContainsKey(_sequenceNumber); + + /// + /// Gets the list of pending orchestrator actions to be sent to the Dapr sidecar. + /// + internal IReadOnlyList PendingActions => _pendingActions; + /// + /// Gets the custom status set by the workflow, if any. + /// + internal object? CustomStatus => _customStatus; + + /// + public override Task CallActivityAsync(string name, object? input = null, + WorkflowTaskOptions? options = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + var taskId = _sequenceNumber++; + + // Check Past Events (true replay) + if (_pastEventMap.TryGetValue(taskId, out var historyEvent)) + { + _logger.LogCallActivityPastHistoryMatch(taskId); + return HandleHistoryMatch(name, historyEvent, taskId, isReplay: true); + } + + // Check new events (advancing execution) + if (_newEventMap.TryGetValue(taskId, out historyEvent)) + { + _logger.LogCallActivityNewHistoryMatch(taskId); + return HandleHistoryMatch(name, historyEvent, taskId, isReplay: false); + } + + // Check if already scheduled (Pending) + if (_scheduledEventIds.Contains(taskId)) + { + _logger.LogCallActivityPendingMatch(taskId, name); + return new TaskCompletionSource().Task; + } + + // Not in history - schedule new activity execution + _logger.LogSchedulingActivity(name, InstanceId, taskId); + + _pendingActions.Add(new OrchestratorAction + { + Id = taskId, + ScheduleTask = new ScheduleTaskAction { Name = name, Input = workflowSerializer.Serialize(input) }, + Router = !string.IsNullOrEmpty(options?.AppId) ? new TaskRouter { TargetAppID = options.AppId } : null + }); + + // Return a task that will never complete on this execution. It will only complete on + // a future replay when the result is in history + return new TaskCompletionSource().Task; + } + + /// + public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken) + { + var taskId = _sequenceNumber++; + + // Check history for timer completion in both maps + if (_pastEventMap.TryGetValue(taskId, out var historyEvent) || + _newEventMap.TryGetValue(taskId, out historyEvent)) + { + if (historyEvent.TimerFired is not null) + { + _logger.LogCreateTimerMatch(taskId); + return Task.CompletedTask; + } + } + + // Check if already scheduled (Pending) + if (_scheduledEventIds.Contains(taskId)) + { + _logger.LogCreateTimerPending(taskId); + return new TaskCompletionSource().Task; + } + + // Schedule new timer + _logger.LogSchedulingTimer(fireAt, InstanceId); + + _pendingActions.Add(new OrchestratorAction + { + Id = taskId, + CreateTimer = new CreateTimerAction + { + FireAt = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime(fireAt) + } + }); + + return new TaskCompletionSource().Task; + } + + /// + public override Task WaitForExternalEventAsync(string eventName, CancellationToken cancellationToken = default) + { + // IMPORTANT: External events are matched by name in Dapr, NOT by sequence. + // Do NOT increment _sequenceNumber here. Doing so will misalign all subsequent tasks. + var historyEvent = _pastEvents.Concat(_newEvents) + .FirstOrDefault(e => e.EventRaised is { } er && string.Equals(er.Name, eventName, StringComparison.OrdinalIgnoreCase)); + + if (historyEvent != null) + { + _consumedExternalEvents.Add(eventName); + var eventData = historyEvent.EventRaised.Input ?? string.Empty; + return Task.FromResult(DeserializeResult(eventData)); + } + + // Event not in history yet + var tcs = new TaskCompletionSource(); + + if (cancellationToken.CanBeCanceled) + { + cancellationToken.Register(() => tcs.TrySetCanceled(cancellationToken)); + } + + return tcs.Task; + } + + /// + public override async Task WaitForExternalEventAsync(string eventName, TimeSpan timeout) + { + using var cts = new CancellationTokenSource(timeout); + return await WaitForExternalEventAsync(eventName, cts.Token).ConfigureAwait(false); + } + + /// + public override void SendEvent(string instanceId, string eventName, object payload) + { + ArgumentException.ThrowIfNullOrWhiteSpace(instanceId); + ArgumentException.ThrowIfNullOrWhiteSpace(eventName); + + _pendingActions.Add(new OrchestratorAction + { + Id = _sequenceNumber++, + SendEvent = new SendEventAction + { + Instance = new OrchestrationInstance{InstanceId = instanceId}, + Name = eventName, + Data = workflowSerializer.Serialize(payload) + } + }); + } + + /// + public override void SetCustomStatus(object? customStatus) => _customStatus = customStatus; + + public override Task CallChildWorkflowAsync(string workflowName, object? input = null, + ChildWorkflowTaskOptions? options = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(workflowName); + + // CRITICAL: Child instance IDs must be deterministic across replays + // Generate the child instance ID BEFORE incrementing sequence + var childInstanceId = options?.InstanceId ?? NewGuid().ToString(); + var taskId = _sequenceNumber++; + + // Try standard TaskScheduledId-based matching first (fast path) + if (_pastEventMap.TryGetValue(taskId, out var historyEvent)) + { + _logger.LogCallChildWorkflowPastHistoryMatch(taskId); + return HandleHistoryMatch(workflowName, historyEvent, taskId, isReplay: true); + } + + if (_newEventMap.TryGetValue(taskId, out historyEvent)) + { + _logger.LogCallChildWorkflowNewHistoryMatch(taskId); + return HandleHistoryMatch(workflowName, historyEvent, taskId, isReplay: false); + } + + // Check if already scheduled (Pending) via deterministic TaskID + if (_scheduledEventIds.Contains(taskId)) + { + _logger.LogCallChildWorkflowPendingMatch(taskId, workflowName); + return new TaskCompletionSource().Task; + } + + // FALLBACK: If TaskScheduledId doesn't match, search by child instance ID + // This handles cases where Dapr returns events with mismatched TaskScheduledId values + var completionByInstanceId = _newEvents + .Concat(_pastEvents) + .FirstOrDefault(e => + { + // Check if this is a SubOrchestrationInstanceCreated event with matching instance ID + if (e.SubOrchestrationInstanceCreated != null) + { + return string.Equals(e.SubOrchestrationInstanceCreated.InstanceId, childInstanceId, + StringComparison.OrdinalIgnoreCase); + } + + return false; + }); + + if (completionByInstanceId != null) + { + // We found the CREATION event. The task is at least running. + var createdTaskId = completionByInstanceId.EventId; + + // Try to find the completion using the ID we found in the creation event + var completion = _newEvents.Concat(_pastEvents) + .FirstOrDefault(e => + (e.SubOrchestrationInstanceCompleted != null && + e.SubOrchestrationInstanceCompleted.TaskScheduledId == createdTaskId) || + (e.SubOrchestrationInstanceFailed != null && + e.SubOrchestrationInstanceFailed.TaskScheduledId == createdTaskId)); + + if (completion != null) + { + _logger.LogCallChildWorkflowFoundCompletion(taskId, childInstanceId); + return HandleHistoryMatch(workflowName, completion, taskId, isReplay: false); + } + + // Found Creation but NO Completion -> Task is PENDING + _logger.LogCallChildWorkflowFoundRunning(taskId); + return new TaskCompletionSource().Task; + } + + _logger.LogCallChildWorkflowSchedulingNew(workflowName, taskId, childInstanceId); + var action = new OrchestratorAction + { + Id = taskId, + CreateSubOrchestration = new CreateSubOrchestrationAction + { + Name = workflowName, InstanceId = childInstanceId, Input = workflowSerializer.Serialize(input) + }, + Router = !string.IsNullOrEmpty(options?.AppId) ? new TaskRouter { TargetAppID = options.AppId } : null + }; + + _pendingActions.Add(action); + + return new TaskCompletionSource().Task; + } + + /// + public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) + { + var action = new OrchestratorAction + { + Id = _sequenceNumber++, + CompleteOrchestration = new CompleteOrchestrationAction + { + OrchestrationStatus = OrchestrationStatus.ContinuedAsNew, + Result = workflowSerializer.Serialize(newInput), + } + }; + + if (preserveUnprocessedEvents) + { + // Find all EventRaised events that were not consumed via WaitForExternalEventAsync + var carryover = _pastEvents.Concat(_newEvents) + .Where(e => e.EventRaised != null && !_consumedExternalEvents.Contains(e.EventRaised.Name)); + + action.CompleteOrchestration.CarryoverEvents.AddRange(carryover); + } + + _pendingActions.Add(action); + } + + /// + public override Guid NewGuid() + { + // Create deterministic Guid based on instance ID and counter + var guidCounter = _guidCounter++; + var name = $"{InstanceId}_{guidCounter}"; // Stable name + return CreateGuidFromName(_instanceGuid, Encoding.UTF8.GetBytes(name)); + } + + /// + public override ILogger CreateReplaySafeLogger(string categoryName) => new ReplaySafeLogger(_logger, () => IsReplaying); + /// + public override ILogger CreateReplaySafeLogger(Type type) => CreateReplaySafeLogger(type.FullName ?? type.Name); + /// + public override ILogger CreateReplaySafeLogger() => CreateReplaySafeLogger(typeof(T)); + + private Task HandleHistoryMatch(string name, HistoryEvent e, int taskId, bool isReplay) + { + _logger.LogHandleHistoryMatch(isReplay ? "Replaying" : "Executing", taskId, name); + + return e switch + { + { TaskCompleted: { } completed } => HandleCompletedActivityFromHistory(name, completed), + { TaskFailed: { } failed } => HandleFailedActivityFromHistory(name, failed), + { SubOrchestrationInstanceCompleted: { } completed } => HandleCompletedChildWorkflowFromHistory(name, completed), + { SubOrchestrationInstanceFailed: { } failed } => HandleFailedChildWorkflowFromHistory(name, failed), + _ => throw new InvalidOperationException($"Unexpected history event type for task ID {taskId}") + }; + } + + /// + /// Extracts the TaskID/ScheduledID from a history event to correlate it with an action. + /// + private static bool TryGetTaskScheduledId(HistoryEvent e, out int scheduledId) + { + if (e.TaskCompleted != null) { scheduledId = e.TaskCompleted.TaskScheduledId; return true; } + if (e.TaskFailed != null) { scheduledId = e.TaskFailed.TaskScheduledId; return true; } + if (e.TimerFired != null) { scheduledId = e.TimerFired.TimerId; return true; } + if (e.SubOrchestrationInstanceCompleted != null) { scheduledId = e.SubOrchestrationInstanceCompleted.TaskScheduledId; return true; } + if (e.SubOrchestrationInstanceFailed != null) { scheduledId = e.SubOrchestrationInstanceFailed.TaskScheduledId; return true; } + + scheduledId = -1; + return false; + } + + /// + /// Handles an activity that completed in the workflow history. + /// + private Task HandleCompletedActivityFromHistory(string activityName, TaskCompletedEvent completed) + { + _logger.LogActivityCompletedFromHistory(activityName, InstanceId); + return Task.FromResult(DeserializeResult(completed.Result ?? string.Empty)); + } + + /// + /// Handles an activity that failed in the workflow history. + /// + private Task HandleFailedActivityFromHistory(string activityName, TaskFailedEvent failed) + { + _logger.LogActivityFailedFromHistory(activityName, InstanceId); + throw CreateTaskFailedException(failed); + } + + /// + /// Handles a child workflow that completed in the workflow history. + /// + private Task HandleCompletedChildWorkflowFromHistory(string workflowName, + SubOrchestrationInstanceCompletedEvent completed) + { + _logger.LogChildWorkflowCompletedFromHistory(workflowName, InstanceId); + return Task.FromResult(DeserializeResult(completed.Result ?? string.Empty)); + } + + /// + /// Handles a child workflow that failed in the workflow history. + /// + private Task HandleFailedChildWorkflowFromHistory(string workflowName, + SubOrchestrationInstanceFailedEvent failed) + { + _logger.LogChildWorkflowFailedFromHistory(workflowName, InstanceId); + throw new WorkflowTaskFailedException($"Child workflow '{workflowName}' failed", + new WorkflowTaskFailureDetails(failed.FailureDetails?.ErrorType ?? "Exception", + failed.FailureDetails?.ErrorMessage ?? "Unknown message", + failed.FailureDetails?.StackTrace ?? string.Empty)); + } + + /// + /// Creates a deterministic GUID from a namespace and name using RFC 4122 UUID v5 (SHA-1). + /// + private static Guid CreateGuidFromName(Guid namespaceId, byte[] name) + { + // RFC 4122 §4.3 - Algorithm for Creating a Name-Based UUID (Version 5 - SHA-1) + var namespaceBytes = namespaceId.ToByteArray(); + SwapByteOrder(namespaceBytes); + + byte[] hash; + using (var sha1 = SHA1.Create()) + { + sha1.TransformBlock(namespaceBytes, 0, namespaceBytes.Length, null, 0); + sha1.TransformFinalBlock(name, 0, name.Length); + hash = sha1.Hash!; + } + + var newGuid = new byte[16]; + Array.Copy(hash, 0, newGuid, 0, 16); + + // Set version to 5 (SHA-1) and variant to RFC 4122 + newGuid[6] = (byte)((newGuid[6] & 0x0F) | 0x50); + newGuid[8] = (byte)((newGuid[8] & 0x3F) | 0x80); + + SwapByteOrder(newGuid); + return new Guid(newGuid); + } + + /// + /// Swaps byte order for GUID conversion. + /// + private static void SwapByteOrder(byte[] guid) + { + SwapBytes(guid, 0, 3); + SwapBytes(guid, 1, 2); + SwapBytes(guid, 4, 5); + SwapBytes(guid, 6, 7); + } + + /// + /// Swaps two bytes in an array. + /// + private static void SwapBytes(byte[] guid, int left, int right) + => (guid[left], guid[right]) = (guid[right], guid[left]); + + /// + /// Deserializes string value to a specific type. + /// + /// The string value to deserialize. + /// The type to deserialize to. + /// The strongly typed deserialized data. + private T DeserializeResult(string value) + => string.IsNullOrEmpty(value) ? default! : workflowSerializer.Deserialize(value)!; + + /// + /// An exception that represents a task failed event. + /// + private static WorkflowTaskFailedException CreateTaskFailedException(TaskFailedEvent failedEvent) + { + var failureDetails = new WorkflowTaskFailureDetails(failedEvent.FailureDetails?.ErrorType ?? "Exception", + failedEvent.FailureDetails?.ErrorMessage ?? "Unknown error", + failedEvent.FailureDetails?.StackTrace ?? string.Empty); + + return new WorkflowTaskFailedException($"Task failed: {failureDetails.ErrorMessage}", failureDetails); + } +} diff --git a/src/Dapr.Workflow/Worker/WorkflowWorker.cs b/src/Dapr.Workflow/Worker/WorkflowWorker.cs new file mode 100644 index 000000000..606de12d8 --- /dev/null +++ b/src/Dapr.Workflow/Worker/WorkflowWorker.cs @@ -0,0 +1,327 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Abstractions; +using Dapr.Workflow.Serialization; +using Dapr.Workflow.Worker.Grpc; +using Dapr.Workflow.Worker.Internal; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Dapr.Workflow.Worker; + +/// +/// Background service that processes workflow and activity work items from the Dapr sidecar. +/// +internal sealed class WorkflowWorker(TaskHubSidecarService.TaskHubSidecarServiceClient grpcClient, IWorkflowsFactory workflowsFactory, ILoggerFactory loggerFactory, IWorkflowSerializer workflowSerializer, IServiceProvider serviceProvider, WorkflowRuntimeOptions options) : BackgroundService +{ + private readonly TaskHubSidecarService.TaskHubSidecarServiceClient _grpcClient = grpcClient ?? throw new ArgumentNullException(nameof(grpcClient)); + private readonly IWorkflowsFactory _workflowsFactory = workflowsFactory ?? throw new ArgumentNullException(nameof(workflowsFactory)); + private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); + private readonly ILogger _logger = loggerFactory?.CreateLogger() ?? throw new ArgumentNullException(nameof(loggerFactory)); + private readonly WorkflowRuntimeOptions _options = options ?? throw new ArgumentNullException(nameof(options)); + private readonly IWorkflowSerializer _serializer = workflowSerializer ?? throw new ArgumentNullException(nameof(workflowSerializer)); + + private GrpcProtocolHandler? _protocolHandler; + + /// + /// Executes the workflow worker. + /// + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogWorkerWorkflowStart(); + + try + { + // Create the protocol handler + _protocolHandler = new GrpcProtocolHandler(_grpcClient, loggerFactory, _options.MaxConcurrentWorkflows, _options.MaxConcurrentActivities); + + // Start processing work items + await _protocolHandler.StartAsync(HandleOrchestratorResponseAsync, HandleActivityResponseAsync, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + _logger.LogWorkerWorkflowCanceled(); + } + catch (Exception ex) + { + _logger.LogWorkerWorkflowError(ex); + throw; + } + } + + private async Task HandleOrchestratorResponseAsync(OrchestratorRequest request) + { + _logger.LogWorkerWorkflowHandleOrchestratorRequestStart(request.InstanceId); + + try + { + // Create a scope for DI + await using var scope = _serviceProvider.CreateAsyncScope(); + + // We must collect ALL past events, including those from the stream if required + // Failure to do this causes the orchestrator to have a "blind spot" in its history at scale + var allPastEvents = request.PastEvents.ToList(); + + // Extract the workflow name from the ExecutionStartedEvent in the history + string? workflowName = null; + string? serializedInput = null; + + if (request.RequiresHistoryStreaming) + { + var streamRequest = new StreamInstanceHistoryRequest + { + InstanceId = request.InstanceId, ExecutionId = request.ExecutionId, ForWorkItemProcessing = true + }; + + using var call = _grpcClient.StreamInstanceHistory(streamRequest); + while (await call.ResponseStream.MoveNext(CancellationToken.None).ConfigureAwait(false)) + { + var chunk = call.ResponseStream.Current.Events; + allPastEvents.AddRange(chunk); + } + } + + // Identify the workflow name from the now-complete history + foreach (var e in allPastEvents.Concat(request.NewEvents)) + { + if (e.ExecutionStarted != null) + { + workflowName = e.ExecutionStarted.Name; + serializedInput = e.ExecutionStarted.Input; + break; + } + } + + if (string.IsNullOrEmpty(workflowName)) + { + _logger.LogWorkerWorkflowHandleOrchestratorRequestNotInRegistry(""); + return new OrchestratorResponse { InstanceId = request.InstanceId }; + } + + // Try to get the workflow from the factory + var workflowIdentifier = new TaskIdentifier(workflowName); + if (!_workflowsFactory.TryCreateWorkflow(workflowIdentifier, scope.ServiceProvider, out var workflow)) + { + _logger.LogWorkerWorkflowHandleOrchestratorRequestNotInRegistry(workflowName); + return new OrchestratorResponse { InstanceId = request.InstanceId}; + } + + var currentUtcDateTime = allPastEvents.Count > 0 && allPastEvents[0].Timestamp != null + ? allPastEvents[0].Timestamp.ToDateTime() + : DateTime.UtcNow; + + // Initialize the context with the FULL history + var context = new WorkflowOrchestrationContext(workflowName, request.InstanceId, allPastEvents, request.NewEvents, currentUtcDateTime, _serializer, loggerFactory); + + // Deserialize the input + object? input = string.IsNullOrEmpty(serializedInput) + ? null + : _serializer.Deserialize(serializedInput, workflow!.InputType); + + // Execute the workflow + // IMPORTANT: Durable orchestrations intentionally "block" on incomplete tasks (activities, timers, events) + // during the first execution pass. We must NOT await indefinitely here; we need to return the pending actions. + var runTask = workflow!.RunAsync(context, input); + + // Get all pending actions from the context + var response = new OrchestratorResponse { InstanceId = request.InstanceId }; + + // Add all actions that were scheduled during workflow execution + response.Actions.AddRange(context.PendingActions); + + // Set custom status if provided + if (context.CustomStatus != null) + response.CustomStatus = _serializer.Serialize(context.CustomStatus); + + // If the workflow issued ContinueAsNew, it already queued a completion action; just return it. + if (context.PendingActions.Any(a => a.CompleteOrchestration?.OrchestrationStatus == OrchestrationStatus.ContinuedAsNew)) + { + _logger.LogWorkerWorkflowHandleOrchestratorRequestCompleted(workflowName, request.InstanceId); + return response; + } + + if (!runTask.IsCompleted) + { + _logger.LogWorkflowWorkerOrchestratorYield(request.InstanceId, response.Actions.Count, context.PendingActions.Count); + + if (response.Actions.Count == 0 && !context.PendingActions.Any()) + { + _logger.LogWorkflowWorkerOrchestratorStall(request.InstanceId); + } + return response; + } + + // If we are here, the workflow method has finished - we must handle the result or exception + try + { + // The workflow completed synchronously (either on replay or it had nothing to await). + // Observe exceptions if any, otherwise serialize the output and complete the orchestration. + var output = await runTask.ConfigureAwait(false); + + var outputJson = output != null ? _serializer.Serialize(output) : string.Empty; + + response.Actions.Add(new OrchestratorAction + { + CompleteOrchestration = new CompleteOrchestrationAction + { + Result = outputJson, + OrchestrationStatus = OrchestrationStatus.Completed + } + }); + } + catch (Exception ex) + { + // Report the failure as an action so Dapr records the workflow as FAILED + response.Actions.Add(new OrchestratorAction + { + CompleteOrchestration = new CompleteOrchestrationAction + { + OrchestrationStatus = OrchestrationStatus.Failed, + FailureDetails = new() + { + ErrorType = ex.GetType().FullName ?? "Exception", + ErrorMessage = ex.Message, + StackTrace = ex.StackTrace ?? string.Empty + } + } + }); + } + + _logger.LogWorkerWorkflowHandleOrchestratorRequestCompleted(workflowName, request.InstanceId); + return response; + } + catch (Exception ex) + { + _logger.LogWorkerWorkflowHandleOrchestratorRequestFailed(ex, request.InstanceId); + + return new OrchestratorResponse + { + InstanceId = request.InstanceId, + Actions = + { + new OrchestratorAction + { + CompleteOrchestration = new() + { + OrchestrationStatus = OrchestrationStatus.Failed, + FailureDetails = new() + { + ErrorType = ex.GetType().FullName ?? "Exception", + ErrorMessage = ex.Message, + StackTrace = ex.StackTrace ?? string.Empty + } + } + } + } + }; + } + } + + private async Task HandleActivityResponseAsync(ActivityRequest request) + { + _logger.LogWorkerWorkflowHandleActivityRequestStart(request.Name, request.OrchestrationInstance?.InstanceId, request.TaskId); + + try + { + // Create a scope for DI + await using var scope = _serviceProvider.CreateAsyncScope(); + + // Try to get the activity from the factory + var activityIdentifier = new TaskIdentifier(request.Name); + if (!_workflowsFactory.TryCreateActivity(activityIdentifier, scope.ServiceProvider, out var activity)) + { + _logger.LogWorkerWorkflowHandleActivityRequestNotInRegistry(request.Name); + + return new ActivityResponse + { + InstanceId = request.OrchestrationInstance?.InstanceId ?? string.Empty, + TaskId = request.TaskId, + FailureDetails = new() + { + ErrorType = "ActivityNotFoundException", + ErrorMessage = $"Activity '{request.Name}' not found", + StackTrace = string.Empty + } + }; + } + + // Create the activity context + var taskExecutionKey = !string.IsNullOrEmpty(request.TaskExecutionId) + ? request.TaskExecutionId + : request.TaskId.ToString(); + + var context = new WorkflowActivityContextImpl(activityIdentifier, + request.OrchestrationInstance?.InstanceId ?? string.Empty, taskExecutionKey); + + // Deserialize the input + object? input = null; + if (!string.IsNullOrEmpty(request.Input)) + { + input = _serializer.Deserialize(request.Input, activity!.InputType); + } + + // Execute the activity + var output = await activity!.RunAsync(context, input); + + // Serialize output + var outputJson = output != null + ? _serializer.Serialize(output) + : string.Empty; + + _logger.LogWorkerWorkflowHandleActivityRequestCompleted(request.Name, request.TaskId); + + return new ActivityResponse + { + InstanceId = request.OrchestrationInstance?.InstanceId ?? string.Empty, + TaskId = request.TaskId, + Result = outputJson + }; + } + catch(Exception ex) + { + _logger.LogWorkerWorkflowHandleActivityRequestFailed(ex, request.Name, request.OrchestrationInstance?.InstanceId); + + return new ActivityResponse + { + InstanceId = request.OrchestrationInstance?.InstanceId ?? string.Empty, + TaskId = request.TaskId, + FailureDetails = new() + { + ErrorType = ex.GetType().FullName ?? "Exception", + ErrorMessage = ex.Message, + StackTrace = ex.StackTrace ?? string.Empty + } + }; + } + } + + /// + /// Disposes resources when stopping. + /// + public override async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogWorkerWorkflowStop(); + + if (_protocolHandler != null) + await _protocolHandler.DisposeAsync(); + + await base.StopAsync(cancellationToken); + } +} diff --git a/src/Dapr.Workflow/Worker/WorkflowsFactory.cs b/src/Dapr.Workflow/Worker/WorkflowsFactory.cs new file mode 100644 index 000000000..8b19de576 --- /dev/null +++ b/src/Dapr.Workflow/Worker/WorkflowsFactory.cs @@ -0,0 +1,173 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System; +using System.Collections.Concurrent; +using System.Threading.Tasks; +using Dapr.Workflow.Abstractions; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Dapr.Workflow.Worker; + +/// +/// Factory for creating workflow and activity instances with DI support. +/// +internal sealed class WorkflowsFactory(ILogger logger) : IWorkflowsFactory +{ + private readonly ConcurrentDictionary> _workflowFactories = + new(StringComparer.OrdinalIgnoreCase); + private readonly ConcurrentDictionary> _activityFactories = + new(StringComparer.OrdinalIgnoreCase); + + /// + public void RegisterWorkflow(string? name = null) where TWorkflow : class, IWorkflow + { + name ??= typeof(TWorkflow).Name; + + if (_workflowFactories.TryAdd(name, sp => ActivatorUtilities.CreateInstance(sp))) + { + logger.LogRegisterWorkflowSuccess(name); + } + else + { + logger.LogRegisterWorkflowAlreadyRegistered(name); + } + } + + /// + public void RegisterWorkflow(string name, + Func> implementation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(implementation); + + if (_workflowFactories.TryAdd(name, _ => new FunctionWorkflow(implementation))) + { + logger.LogRegisterWorkflowSuccess(name); + } + else + { + logger.LogRegisterWorkflowAlreadyRegistered(name); + } + } + + /// + public void RegisterActivity(string? name = null) where TActivity : class, IWorkflowActivity + { + name ??= typeof(TActivity).Name; + + if (_activityFactories.TryAdd(name, sp => ActivatorUtilities.CreateInstance(sp))) + { + logger.LogRegisterActivitySuccess(name); + } + else + { + logger.LogRegisterActivityAlreadyRegistered(name); + } + } + + /// + public void RegisterActivity(string name, + Func> implementation) + { + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(implementation); + + // Create a synthetic type that wraps the function + if (_activityFactories.TryAdd(name, _ => new FunctionActivity(implementation))) + { + logger.LogRegisterActivitySuccess(name); + } + else + { + logger.LogRegisterActivityAlreadyRegistered(name); + } + } + + /// + public bool TryCreateWorkflow(TaskIdentifier identifier, IServiceProvider serviceProvider, out IWorkflow? workflow) + { + if (_workflowFactories.TryGetValue(identifier.Name, out var factory)) + { + try + { + workflow = factory(serviceProvider); + logger.LogCreateWorkflowInstanceSuccess(identifier.Name); + return true; + } + catch (Exception ex) + { + logger.LogCreateWorkflowFailure(ex, identifier.Name); + workflow = null; + return false; + } + } + + logger.LogCreateWorkflowNotFoundInRegistry(identifier.Name); + workflow = null; + return false; + } + + /// + public bool TryCreateActivity(TaskIdentifier identifier, IServiceProvider serviceProvider, out IWorkflowActivity? activity) + { + if (_activityFactories.TryGetValue(identifier.Name, out var factory)) + { + try + { + activity = factory(serviceProvider); + logger.LogCreateActivityInstanceSuccess(identifier.Name); + return true; + } + catch (Exception ex) + { + logger.LogCreateActivityFailure(ex, identifier.Name); + activity = null; + return false; + } + } + + logger.LogCreateActivityNotFoundInRegistry(identifier.Name); + activity = null; + return false; + } + + /// + /// Internal wrapper that adapts a function to . + /// + private sealed class FunctionWorkflow(Func> implementation) : IWorkflow + { + public Type InputType => typeof(TInput); + public Type OutputType => typeof(TOutput); + + public async Task RunAsync(WorkflowContext context, object? input) + { + return await implementation(context, (TInput)input!); + } + } + + /// + /// Internal wrapper that adapts a function to . + /// + private sealed class FunctionActivity(Func> implementation) : IWorkflowActivity + { + public Type InputType => typeof(TInput); + public Type OutputType => typeof(TOutput); + + public async Task RunAsync(WorkflowActivityContext context, object? input) + { + return await implementation(context, (TInput)input!); + } + } +} diff --git a/src/Dapr.Workflow/WorkflowLoggingService.cs b/src/Dapr.Workflow/WorkflowLoggingService.cs deleted file mode 100644 index 8cb27cbd8..000000000 --- a/src/Dapr.Workflow/WorkflowLoggingService.cs +++ /dev/null @@ -1,66 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright 2022 The Dapr Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------ - -namespace Dapr.Workflow; - -using System.Collections.Generic; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Configuration; - -/// -/// Defines runtime options for workflows. -/// -internal sealed class WorkflowLoggingService(ILogger logger) : IHostedService -{ - private static readonly HashSet registeredWorkflows = []; - private static readonly HashSet registeredActivities = []; - - public Task StartAsync(CancellationToken cancellationToken) - { - logger.Log(LogLevel.Information, "WorkflowLoggingService started"); - logger.Log(LogLevel.Information, "List of registered workflows"); - foreach (string item in registeredWorkflows) - { - logger.Log(LogLevel.Information, item); - } - - logger.Log(LogLevel.Information, "List of registered activities:"); - foreach (string item in registeredActivities) - { - logger.Log(LogLevel.Information, item); - } - - return Task.CompletedTask; - } - - public Task StopAsync(CancellationToken cancellationToken) - { - logger.Log(LogLevel.Information, "WorkflowLoggingService stopped"); - - return Task.CompletedTask; - } - - public static void LogWorkflowName(string workflowName) - { - registeredWorkflows.Add(workflowName); - } - - public static void LogActivityName(string activityName) - { - registeredActivities.Add(activityName); - } - -} diff --git a/src/Dapr.Workflow/WorkflowRetryPolicy.cs b/src/Dapr.Workflow/WorkflowRetryPolicy.cs deleted file mode 100644 index 896fb70de..000000000 --- a/src/Dapr.Workflow/WorkflowRetryPolicy.cs +++ /dev/null @@ -1,96 +0,0 @@ -// ------------------------------------------------------------------------ -// Copyright 2023 The Dapr Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------ - -using System; -using System.Threading; -using Dapr.DurableTask; - -namespace Dapr.Workflow; - -/// -/// A declarative retry policy that can be configured for activity or child workflow calls. -/// -/// The maximum number of task invocation attempts. Must be 1 or greater. -/// The amount of time to delay between the first and second attempt. -/// -/// The exponential back-off coefficient used to determine the delay between subsequent retries. Must be 1.0 or greater. -/// -/// -/// The maximum time to delay between attempts, regardless of. -/// -/// The overall timeout for retries. -/// -/// The value can be used to specify an unlimited timeout for -/// or . -/// -/// -/// Thrown if any of the following are true: -/// -/// The value for is less than or equal to zero. -/// The value for is less than or equal to . -/// The value for is less than 1.0. -/// The value for is less than . -/// The value for is less than . -/// -/// -public class WorkflowRetryPolicy( - int maxNumberOfAttempts, - TimeSpan firstRetryInterval, - double backoffCoefficient = 1.0, - TimeSpan? maxRetryInterval = null, - TimeSpan? retryTimeout = null) -{ - private readonly RetryPolicy durableRetryPolicy = new( - maxNumberOfAttempts, - firstRetryInterval, - backoffCoefficient, - maxRetryInterval, - retryTimeout); - - /// - /// Gets the max number of attempts for executing a given task. - /// - public int MaxNumberOfAttempts => this.durableRetryPolicy.MaxNumberOfAttempts; - - /// - /// Gets the amount of time to delay between the first and second attempt. - /// - public TimeSpan FirstRetryInterval => this.durableRetryPolicy.FirstRetryInterval; - - /// - /// Gets the exponential back-off coefficient used to determine the delay between subsequent retries. - /// - /// - /// Defaults to 1.0 for no back-off. - /// - public double BackoffCoefficient => this.durableRetryPolicy.BackoffCoefficient; - - /// - /// Gets the maximum time to delay between attempts. - /// - /// - /// Defaults to 1 hour. - /// - public TimeSpan MaxRetryInterval => this.durableRetryPolicy.MaxRetryInterval; - - /// - /// Gets the overall timeout for retries. No further attempts will be made at executing a task after this retry - /// timeout expires. - /// - /// - /// Defaults to . - /// - public TimeSpan RetryTimeout => this.durableRetryPolicy.RetryTimeout; - - internal RetryPolicy GetDurableRetryPolicy() => this.durableRetryPolicy; -} diff --git a/src/Dapr.Workflow/WorkflowRuntimeOptions.cs b/src/Dapr.Workflow/WorkflowRuntimeOptions.cs index d8dd118c4..eb43d19a1 100644 --- a/src/Dapr.Workflow/WorkflowRuntimeOptions.cs +++ b/src/Dapr.Workflow/WorkflowRuntimeOptions.cs @@ -11,186 +11,145 @@ // limitations under the License. // ------------------------------------------------------------------------ +using Grpc.Net.Client; namespace Dapr.Workflow; +using Abstractions; using System; using System.Collections.Generic; using System.Threading.Tasks; -using Dapr.DurableTask; -using Grpc.Net.Client; -using Microsoft.Extensions.DependencyInjection; +using Worker; /// /// Defines runtime options for workflows. /// public sealed class WorkflowRuntimeOptions { + private readonly List> _registrationActions = []; + private int _maxConcurrentWorkflows = 100; + private int _maxConcurrentActivities = 100; + /// - /// Dictionary to name and register a workflow. - /// - readonly Dictionary> factories = new(); - - /// - /// For testing. + /// Gets the maximum number of concurrent workflow instances that can be executed at the same time. /// - internal IReadOnlyDictionary> FactoriesInternal => this.factories; + /// + /// The default is 100. Setting this to a higher value can improve throughput but will also increase memory + /// usage. + /// + /// Thrown when value is less than 1. + public int MaxConcurrentWorkflows + { + get => _maxConcurrentWorkflows; + set + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); + _maxConcurrentWorkflows = value; + } + } /// - /// Override GrpcChannelOptions. - /// - internal GrpcChannelOptions? GrpcChannelOptions { get; private set; } - - /// - /// Initializes a new instance of the class. + /// Gets the maximum number of concurrent activities that can be executed at the same time. /// /// - /// Instances of this type are expected to be instantiated from a dependency injection container. + /// The default value is 100. Setting this to a higher value can improve throughput, but will also increase + /// memory usage. /// - public WorkflowRuntimeOptions() + /// Thrown when value is less than 1. + public int MaxConcurrentActivities { + get => _maxConcurrentActivities; + set + { + ArgumentOutOfRangeException.ThrowIfLessThan(value, 1); + _maxConcurrentActivities = value; + } } + + /// + /// Gets or sets the gRPC channel options used for connecting to the Dapr sidecar. + /// + internal GrpcChannelOptions? GrpcChannelOptions { get; private set; } /// /// Registers a workflow as a function that takes a specified input type and returns a specified output type. /// - /// Workflow name - /// Function implementing the workflow definition + /// The type of the workflow input. + /// The type of the workflow output. + /// Workflow name. + /// Function implementing the workflow definition. public void RegisterWorkflow(string name, Func> implementation) { - // Dapr workflows are implemented as specialized Durable Task orchestrations - this.factories.Add(name, (DurableTaskRegistry registry) => - { - registry.AddOrchestratorFunc(name, (innerContext, input) => - { - WorkflowContext workflowContext = new DaprWorkflowContext(innerContext); - return implementation(workflowContext, input); - }); - WorkflowLoggingService.LogWorkflowName(name); - }); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(implementation); + + // Store as a registration action to be applied to WorkflowsFactory later + _registrationActions.Add(factory => factory.RegisterWorkflow(name, implementation)); } /// /// Registers a workflow class that derives from . /// - /// Workflow name. If not specified, then the name of is used. /// The type to register. - public void RegisterWorkflow(string? name = null) where TWorkflow : class, IWorkflow, new() + /// + /// Workflow name. If not specified, then the name of is used. + /// + public void RegisterWorkflow(string? name = null) where TWorkflow : class, IWorkflow { - name ??= typeof(TWorkflow).Name; - - // Dapr workflows are implemented as specialized Durable Task orchestrations - this.factories.Add(name, (DurableTaskRegistry registry) => - { - registry.AddOrchestrator(name, () => - { - TWorkflow workflow = Activator.CreateInstance(); - return new OrchestratorWrapper(workflow); - }); - WorkflowLoggingService.LogWorkflowName(name); - }); + _registrationActions.Add(factory => factory.RegisterWorkflow(name)); } - + /// /// Registers a workflow activity as a function that takes a specified input type and returns a specified output type. /// - /// Activity name - /// Activity implemetation + /// The type of the activity input. + /// The type of the activity output. + /// Activity name. + /// Activity implementation. public void RegisterActivity(string name, Func> implementation) { - // Dapr activities are implemented as specialized Durable Task activities - this.factories.Add(name, (DurableTaskRegistry registry) => - { - registry.AddActivityFunc(name, (innerContext, input) => - { - WorkflowActivityContext activityContext = new DaprWorkflowActivityContext(innerContext); - return implementation(activityContext, input); - }); - WorkflowLoggingService.LogActivityName(name); - }); + ArgumentException.ThrowIfNullOrWhiteSpace(name); + ArgumentNullException.ThrowIfNull(implementation); + + _registrationActions.Add(factory => factory.RegisterActivity(name, implementation)); } - + /// /// Registers a workflow activity class that derives from . /// - /// Activity name. If not specified, then the name of is used. /// The type to register. - public void RegisterActivity(string? name = null) where TActivity : class, IWorkflowActivity + /// + /// Activity name. If not specified, then the name of is used. + /// + public void RegisterActivity(string? name = null) + where TActivity : class, IWorkflowActivity { - name ??= typeof(TActivity).Name; - - // Dapr workflows are implemented as specialized Durable Task orchestrations - this.factories.Add(name, (DurableTaskRegistry registry) => - { - registry.AddActivity(name, serviceProvider => - { - // Workflow activity classes support dependency injection. - TActivity activity = ActivatorUtilities.CreateInstance(serviceProvider); - return new ActivityWrapper(activity); - }); - WorkflowLoggingService.LogActivityName(name); - }); + _registrationActions.Add(factory => factory.RegisterActivity(name)); } - + /// /// Uses the provided for creating the . /// - /// The to use for creating the . + /// + /// The to use for creating the . + /// public void UseGrpcChannelOptions(GrpcChannelOptions grpcChannelOptions) { - this.GrpcChannelOptions = grpcChannelOptions; - } - - /// - /// Method to add workflows and activities to the registry. - /// - /// The registry we will add workflows and activities to - internal void AddWorkflowsAndActivitiesToRegistry(DurableTaskRegistry registry) - { - foreach (Action factory in this.factories.Values) - { - factory.Invoke(registry); // This adds workflows to the registry indirectly. - } + ArgumentNullException.ThrowIfNull(grpcChannelOptions); + GrpcChannelOptions = grpcChannelOptions; } - + /// - /// Helper class that provides a Durable Task orchestrator wrapper for a workflow. + /// Applies all registrations to the provided factory. /// - class OrchestratorWrapper : ITaskOrchestrator - { - readonly IWorkflow workflow; - - public OrchestratorWrapper(IWorkflow workflow) - { - this.workflow = workflow; - } - - public Type InputType => this.workflow.InputType; - - public Type OutputType => this.workflow.OutputType; - - public Task RunAsync(TaskOrchestrationContext context, object? input) - { - return this.workflow.RunAsync(new DaprWorkflowContext(context), input); - } - } - - class ActivityWrapper : ITaskActivity + /// The factory to apply registrations to. + internal void ApplyRegistrations(WorkflowsFactory factory) { - readonly IWorkflowActivity activity; - - public ActivityWrapper(IWorkflowActivity activity) - { - this.activity = activity; - } - - public Type InputType => this.activity.InputType; - - public Type OutputType => this.activity.OutputType; - - public Task RunAsync(TaskActivityContext context, object? input) + ArgumentNullException.ThrowIfNull(factory); + + foreach (var action in _registrationActions) { - return this.activity.RunAsync(new DaprWorkflowActivityContext(context), input); + action(factory); } } } diff --git a/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs b/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs index 842babfae..c6504d4f6 100644 --- a/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs +++ b/src/Dapr.Workflow/WorkflowServiceCollectionExtensions.cs @@ -12,8 +12,13 @@ // ------------------------------------------------------------------------ using System; -using System.Linq; -using System.Net.Http; +using System.Text.Json; +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Client; +using Dapr.Workflow.Grpc.Extensions; +using Dapr.Workflow.Registration; +using Dapr.Workflow.Serialization; +using Dapr.Workflow.Worker; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; @@ -26,69 +31,254 @@ namespace Dapr.Workflow; /// public static class WorkflowServiceCollectionExtensions { + /// + /// Fluent builder for optional workflow configuration (e.g. serialization registration). + /// + public readonly struct DaprWorkflowBuilder : IDaprWorkflowBuilder + { + internal DaprWorkflowBuilder(IServiceCollection services) => Services = services; + + /// + /// Provides the services in the DI container collection. + /// + public IServiceCollection Services { get; } + + /// + /// Configures a custom workflow serializer to replace the default JSON serializer. + /// + /// The custom serializer instance to use. + public DaprWorkflowBuilder WithSerializer(IWorkflowSerializer serializer) + { + ArgumentNullException.ThrowIfNull(serializer); + Services.Replace(ServiceDescriptor.Singleton(typeof(IWorkflowSerializer), serializer)); + return this; + } + + /// + /// Configures a custom workflow serializer using a factory method. + /// + /// A factory function that creates the serializer using the service provider. + public DaprWorkflowBuilder WithSerializer(Func serializerFactory) + { + ArgumentNullException.ThrowIfNull(serializerFactory); + + Services.Replace(ServiceDescriptor.Singleton(typeof(IWorkflowSerializer), serializerFactory)); + return this; + } + + /// + /// Configures the default System.Text.Json serializer with custom options. + /// + /// The JSON serializer options to use. + public DaprWorkflowBuilder WithJsonSerializer(JsonSerializerOptions jsonOptions) + { + ArgumentNullException.ThrowIfNull(jsonOptions); + return WithSerializer(new JsonWorkflowSerializer(jsonOptions)); + } + } + + /// + /// Adds Dapr Workflow support with defaults. + /// + public static IServiceCollection AddDaprWorkflow(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + AddDaprWorkflowCore(services, _ => { }, ServiceLifetime.Singleton); + return services; + } + /// /// Adds Dapr Workflow support to the service collection. /// - /// The . - /// A delegate used to configure actor options and register workflow functions. + public static IServiceCollection AddDaprWorkflow(this IServiceCollection services, Action configure, ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + + AddDaprWorkflowCore(services, configure, lifetime); + return services; + } + + /// + /// Adds Dapr Workflow to the dependency injection container and returns a builder for additional optional configuration. + /// + public static DaprWorkflowBuilder AddDaprWorkflowBuilder(this IServiceCollection services, + Action? configureRuntime, + Action? configureClient = null, + ServiceLifetime lifetime = ServiceLifetime.Singleton) + { + ArgumentNullException.ThrowIfNull(services); + AddDaprWorkflowCore(services, configureRuntime ?? (_ => { }), configureClient, lifetime); + return new DaprWorkflowBuilder(services); + } + + /// + /// Adds Dapr Workflow client support only (without the worker or runtime). + /// This method is useful for scenarios where you only need to interact with workflows without hosting them. + /// + /// The service collection. + /// Optional configuration for the workflow client (e.g., setting gRPC/HTTP endpoints). /// The lifetime of the registered services. - public static IServiceCollection AddDaprWorkflow( - this IServiceCollection serviceCollection, - Action configure, + /// A builder for additional workflow configuration. + public static DaprWorkflowBuilder AddDaprWorkflowClient( + this IServiceCollection services, + Action? configure = null, ServiceLifetime lifetime = ServiceLifetime.Singleton) { - ArgumentNullException.ThrowIfNull(serviceCollection, nameof(serviceCollection)); + ArgumentNullException.ThrowIfNull(services); + + services.AddHttpClient(); + + var registration = new Func(provider => + { + var configuration = provider.GetService(); + var builder = new DaprWorkflowClientBuilder(configuration); + + builder.UseDaprApiToken(DaprDefaults.GetDefaultDaprApiToken(configuration)); + builder.UseServiceProvider(provider); + + // Apply custom configuration + configure?.Invoke(provider, builder); + + return builder.Build(); + }); + + services.Add(new ServiceDescriptor(typeof(DaprWorkflowClient), registration, lifetime)); + return new DaprWorkflowBuilder(services); + } - serviceCollection.AddDaprClient(lifetime: lifetime); + private static void AddDaprWorkflowCore( + IServiceCollection serviceCollection, + Action configure, + Action? configureClient, + ServiceLifetime lifetime) + { + // Configure workflow runtime options + var options = new WorkflowRuntimeOptions(); + configure(options); + + // Register options as a singleton as they don't change at runtime + serviceCollection.AddSingleton(options); + + // Register default JSON serializer if no custom serializer is registered + serviceCollection.TryAddSingleton( + new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web))); + + // Register the workflow factory + serviceCollection.TryAddSingleton(sp => + { + var loggerFactory = sp.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var factory = new WorkflowsFactory(logger); + + // Apply all registrations from options + options.ApplyRegistrations(factory); + + return factory; + }); + + // Necessary for the gRPC client factory serviceCollection.AddHttpClient(); - serviceCollection.AddHostedService(); - - // Configure default logging levels for the DurableTask packages (can be overridden by consumer in appsettings.json) - serviceCollection.Configure(options => + + // Register the internal WorkflowClient implementation + serviceCollection.TryAddSingleton(sp => + { + var grpcClient = sp.GetRequiredService(); + var loggerFactory = sp.GetRequiredService(); + var logger = loggerFactory.CreateLogger(); + var serializer = sp.GetRequiredService(); + return new WorkflowGrpcClient(grpcClient, logger, serializer); + }); + + // Register gRPC client for communicating with Dapr sidecar + serviceCollection.AddDaprWorkflowGrpcClient(grpcOptions => + { + if (options.GrpcChannelOptions != null) + { + grpcOptions.ChannelOptionsActions.Add(channelOptions => + { + if (options.GrpcChannelOptions.MaxReceiveMessageSize.HasValue) + { + channelOptions.MaxReceiveMessageSize = options.GrpcChannelOptions.MaxReceiveMessageSize; + } + + if (options.GrpcChannelOptions.MaxSendMessageSize.HasValue) + { + channelOptions.MaxSendMessageSize = options.GrpcChannelOptions.MaxSendMessageSize; + } + }); + } + }); + + // Register the workflow worker as a hosted service + serviceCollection.AddHostedService(); + + // Register the workflow client - use builder pattern if custom configuration provided + if (configureClient != null) { - if (!HasExistingFilter(options, "Dapr.DurableTask.Grpc")) - options.AddFilter("Dapr.DurableTask.Grpc", LogLevel.Error); - if (!HasExistingFilter(options, "Dapr.DurableTask.Client.Grpc")) - options.AddFilter("Dapr.DurableTask.Client.Grpc", LogLevel.Error); - if (!HasExistingFilter(options, "Dapr.DurableTask.Worker")) - options.AddFilter("Dapr.DurableTask.Worker", LogLevel.Error); - if (!HasExistingFilter(options, "Dapr.DurableTask.Worker.Grpc")) - options.AddFilter("Dapr.DurableTask.Worker.Grpc", LogLevel.Error); + RegisterWorkflowClientWithBuilder(serviceCollection, configureClient, lifetime); + } + else + { + RegisterWorkflowClient(serviceCollection, lifetime); + } + } + + private static void AddDaprWorkflowCore(IServiceCollection serviceCollection, + Action configure, ServiceLifetime lifetime) + { + AddDaprWorkflowCore(serviceCollection, configure, configureClient: null, lifetime); + } + + private static void RegisterWorkflowClientWithBuilder( + IServiceCollection serviceCollection, + Action configureClient, + ServiceLifetime lifetime) + { + var registration = new Func(provider => + { + var configuration = provider.GetService(); + var builder = new DaprWorkflowClientBuilder(configuration); + + builder.UseDaprApiToken(DaprDefaults.GetDefaultDaprApiToken(configuration)); + builder.UseServiceProvider(provider); + + // Apply custom client configuration (endpoints, etc.) + configureClient(provider, builder); + + return builder.Build(); }); + serviceCollection.Add(new ServiceDescriptor(typeof(DaprWorkflowClient), registration, lifetime)); + } + + private static void RegisterWorkflowClient(IServiceCollection serviceCollection, ServiceLifetime lifetime) + { switch (lifetime) { case ServiceLifetime.Singleton: - serviceCollection.TryAddSingleton(); - serviceCollection.TryAddSingleton(); + serviceCollection.TryAddSingleton(sp => + { + var inner = sp.GetRequiredService(); + return new DaprWorkflowClient(inner); + }); break; case ServiceLifetime.Scoped: - serviceCollection.TryAddScoped(); - serviceCollection.TryAddScoped(); + serviceCollection.TryAddScoped(sp => + { + var inner = sp.GetRequiredService(); + return new DaprWorkflowClient(inner); + }); break; case ServiceLifetime.Transient: - serviceCollection.TryAddTransient(); - serviceCollection.TryAddTransient(); + serviceCollection.TryAddTransient(sp => + { + var inner = sp.GetRequiredService(); + return new DaprWorkflowClient(inner); + }); break; default: - throw new ArgumentOutOfRangeException(nameof(lifetime), lifetime, null); - } - - serviceCollection.AddOptions().Configure(configure); - - //Register the factory and force resolution so the Durable Task client and worker can be registered - using (var scope = serviceCollection.BuildServiceProvider().CreateScope()) - { - var httpClientFactory = scope.ServiceProvider.GetRequiredService(); - var configuration = scope.ServiceProvider.GetService(); - - var factory = new DaprWorkflowClientBuilderFactory(configuration, httpClientFactory); - factory.CreateClientBuilder(serviceCollection, configure); + throw new ArgumentOutOfRangeException(nameof(lifetime), lifetime, "Invalid service lifetime"); } - - return serviceCollection; } - - private static bool HasExistingFilter(LoggerFilterOptions options, string categoryName) - => options.Rules.Any(rule => rule.CategoryName == categoryName); } diff --git a/src/Dapr.Workflow/WorkflowState.cs b/src/Dapr.Workflow/WorkflowState.cs index cd5061724..15dcce08c 100644 --- a/src/Dapr.Workflow/WorkflowState.cs +++ b/src/Dapr.Workflow/WorkflowState.cs @@ -11,85 +11,59 @@ // limitations under the License. // ------------------------------------------------------------------------ -namespace Dapr.Workflow; - +using Dapr.Workflow.Client; using System; -using Dapr.DurableTask.Client; + +namespace Dapr.Workflow; /// /// Represents a snapshot of a workflow instance's current state, including runtime status. /// -public class WorkflowState +public sealed class WorkflowState { - readonly OrchestrationMetadata? workflowState; - readonly WorkflowTaskFailureDetails? failureDetails; + private readonly WorkflowMetadata? _metadata; - internal WorkflowState(OrchestrationMetadata? orchestrationMetadata) + /// + /// Initializes a new instance of the class from workflow metadata. + /// + /// The workflow metadata, or null if the workflow does not exist. + internal WorkflowState(WorkflowMetadata? metadata) { - // This value will be null if the workflow doesn't exist. - this.workflowState = orchestrationMetadata; - if (orchestrationMetadata?.FailureDetails != null) - { - this.failureDetails = new WorkflowTaskFailureDetails(orchestrationMetadata.FailureDetails); - } + _metadata = metadata; } - + /// /// Gets a value indicating whether the requested workflow instance exists. /// - public bool Exists => this.workflowState != null; + public bool Exists => _metadata is not null; /// /// Gets a value indicating whether the requested workflow is in a running state. /// - public bool IsWorkflowRunning => this.workflowState?.RuntimeStatus == OrchestrationRuntimeStatus.Running; + public bool IsWorkflowRunning => _metadata?.RuntimeStatus == WorkflowRuntimeStatus.Running; /// /// Gets a value indicating whether the requested workflow is in a terminal state. /// - public bool IsWorkflowCompleted => this.workflowState?.IsCompleted == true; + public bool IsWorkflowCompleted => _metadata?.RuntimeStatus is + WorkflowRuntimeStatus.Completed or + WorkflowRuntimeStatus.Failed or + WorkflowRuntimeStatus.Terminated; /// /// Gets the time at which this workflow instance was created. /// - public DateTimeOffset CreatedAt => this.workflowState?.CreatedAt ?? default; + public DateTimeOffset CreatedAt => _metadata?.CreatedAt ?? default; /// /// Gets the time at which this workflow instance last had its state updated. /// - public DateTimeOffset LastUpdatedAt => this.workflowState?.LastUpdatedAt ?? default; + public DateTimeOffset LastUpdatedAt => _metadata?.LastUpdatedAt ?? default; /// /// Gets the execution status of the workflow. /// - public WorkflowRuntimeStatus RuntimeStatus - { - get - { - if (this.workflowState == null) - { - return WorkflowRuntimeStatus.Unknown; - } - - switch (this.workflowState.RuntimeStatus) - { - case OrchestrationRuntimeStatus.Running: - return WorkflowRuntimeStatus.Running; - case OrchestrationRuntimeStatus.Completed: - return WorkflowRuntimeStatus.Completed; - case OrchestrationRuntimeStatus.Failed: - return WorkflowRuntimeStatus.Failed; - case OrchestrationRuntimeStatus.Terminated: - return WorkflowRuntimeStatus.Terminated; - case OrchestrationRuntimeStatus.Pending: - return WorkflowRuntimeStatus.Pending; - case OrchestrationRuntimeStatus.Suspended: - return WorkflowRuntimeStatus.Suspended; - default: - return WorkflowRuntimeStatus.Unknown; - } - } - } + public WorkflowRuntimeStatus RuntimeStatus => _metadata?.RuntimeStatus ?? WorkflowRuntimeStatus.Unknown; /// /// Gets the failure details, if any, for the workflow instance. @@ -99,65 +73,26 @@ public WorkflowRuntimeStatus RuntimeStatus /// state, and only if this instance metadata was fetched with the option to include output data. /// /// The failure details if the workflow was in a failed state; null otherwise. - public WorkflowTaskFailureDetails? FailureDetails => this.failureDetails; + public WorkflowTaskFailureDetails? FailureDetails => _metadata?.FailureDetails; /// /// Deserializes the workflow input into . /// /// The type to deserialize the workflow input into. /// Returns the input as , or returns a default value if the workflow doesn't exist. - public T? ReadInputAs() - { - if (this.workflowState == null) - { - return default; - } - - if (string.IsNullOrEmpty(this.workflowState.SerializedInput)) - { - return default; - } - - return this.workflowState.ReadInputAs(); - } + public T? ReadInputAs() => _metadata is null ? default : _metadata.ReadInputAs(); /// /// Deserializes the workflow output into . /// /// The type to deserialize the workflow output into. /// Returns the output as , or returns a default value if the workflow doesn't exist. - public T? ReadOutputAs() - { - if (this.workflowState == null) - { - return default; - } - - if (string.IsNullOrEmpty(this.workflowState.SerializedOutput)) - { - return default; - } - - return this.workflowState.ReadOutputAs(); - } + public T? ReadOutputAs() => _metadata is null ? default : _metadata.ReadOutputAs(); /// /// Deserializes the workflow's custom status into . /// /// The type to deserialize the workflow's custom status into. /// Returns the custom status as , or returns a default value if the workflow doesn't exist. - public T? ReadCustomStatusAs() - { - if (this.workflowState == null) - { - return default; - } - - if (string.IsNullOrEmpty(this.workflowState.SerializedCustomStatus)) - { - return default; - } - - return this.workflowState.ReadCustomStatusAs(); - } + public T? ReadCustomStatusAs() => _metadata is null ? default : _metadata.ReadCustomStatusAs(); } diff --git a/test/Dapr.E2E.Test.PubSub/PubsubTests.cs b/test/Dapr.E2E.Test.PubSub/PubsubTests.cs deleted file mode 100644 index 920a2aa4c..000000000 --- a/test/Dapr.E2E.Test.PubSub/PubsubTests.cs +++ /dev/null @@ -1,98 +0,0 @@ -// // ------------------------------------------------------------------------ -// // Copyright 2025 The Dapr Authors -// // Licensed under the Apache License, Version 2.0 (the "License"); -// // you may not use this file except in compliance with the License. -// // You may obtain a copy of the License at -// // http://www.apache.org/licenses/LICENSE-2.0 -// // Unless required by applicable law or agreed to in writing, software -// // distributed under the License is distributed on an "AS IS" BASIS, -// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// // See the License for the specific language governing permissions and -// // limitations under the License. -// // ------------------------------------------------------------------------ -// -// using Dapr.Client; -// using Dapr.TestContainers; -// using Dapr.TestContainers.Common; -// using Dapr.TestContainers.Common.Options; -// using Microsoft.AspNetCore.Builder; -// using Microsoft.AspNetCore.Hosting; -// using Microsoft.AspNetCore.Http; -// using Microsoft.Extensions.DependencyInjection; -// using Microsoft.Extensions.Hosting; -// -// namespace Dapr.E2E.Test.PubSub; -// -// public class PubsubTests -// { -// [Fact] -// public async Task ShouldEnablePublishAndSubscribe() -// { -// var options = new DaprRuntimeOptions(); -// var componentsDir = Path.Combine(Directory.GetCurrentDirectory(), "pubsub-components"); -// const string topicName = "test-topic"; -// -// WebApplication? app = null; -// -// var messageReceived = new TaskCompletionSource(); -// -// // Build and initialize the test harness -// var harnessBuilder = new DaprHarnessBuilder(options, StartApp); -// var harness = harnessBuilder.BuildPubSub(componentsDir); -// -// try -// { -// await harness.InitializeAsync(); -// -// var testAppBuilder = new HostApplicationBuilder(); -// testAppBuilder.Services.AddDaprClient(); -// using var testApp = testAppBuilder.Build(); -// await using var scope = testApp.Services.CreateAsyncScope(); -// var daprClient = scope.ServiceProvider.GetRequiredService(); -// -// // Use DaprClient to publish a message -// const string testMessage = "Hello!"; -// await daprClient.PublishEventAsync(Constants.DaprComponentNames.PubSubComponentName, topicName, -// testMessage); -// -// // Wait for the app to receive the message via the sidecar -// var result = await messageReceived.Task.WaitAsync(TimeSpan.FromSeconds(10)); -// Assert.Equal(testMessage, result); -// } -// finally -// { -// await harness.DisposeAsync(); -// -// if (app != null) -// await app.DisposeAsync(); -// } -// -// return; -// -// // Define the app startup -// async Task StartApp(int port) -// { -// var builder = WebApplication.CreateBuilder(); -// builder.WebHost.UseUrls($"http://localhost:{port}"); -// builder.Services.AddControllers().AddDapr(); -// -// app = builder.Build(); -// -// // Setup the subscription endpoint -// app.UseCloudEvents(); -// app.MapSubscribeHandler(); -// -// // Endpoint that Dapr will call when a message is published -// app.MapPost("/message-handler", async (HttpContext context) => -// { -// var data = await context.Request.ReadFromJsonAsync(); -// messageReceived.TrySetResult(data?.ToString() ?? "empty"); -// return Results.Ok(); -// }) -// .WithTopic(Constants.DaprComponentNames.PubSubComponentName, topicName); -// -// await app.StartAsync(); -// } -// } -// -// } diff --git a/test/Dapr.E2E.Test.Workflow/WorkflowTests.cs b/test/Dapr.E2E.Test.Workflow/WorkflowTests.cs deleted file mode 100644 index 889c6d1e4..000000000 --- a/test/Dapr.E2E.Test.Workflow/WorkflowTests.cs +++ /dev/null @@ -1,105 +0,0 @@ -// // ------------------------------------------------------------------------ -// // Copyright 2025 The Dapr Authors -// // Licensed under the Apache License, Version 2.0 (the "License"); -// // you may not use this file except in compliance with the License. -// // You may obtain a copy of the License at -// // http://www.apache.org/licenses/LICENSE-2.0 -// // Unless required by applicable law or agreed to in writing, software -// // distributed under the License is distributed on an "AS IS" BASIS, -// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// // See the License for the specific language governing permissions and -// // limitations under the License. -// // ------------------------------------------------------------------------ -// -// using Dapr.TestContainers.Common; -// using Dapr.TestContainers.Common.Options; -// using Dapr.Workflow; -// using Microsoft.AspNetCore.Builder; -// using Microsoft.AspNetCore.Hosting; -// using Microsoft.Extensions.DependencyInjection; -// using Microsoft.Extensions.Logging; -// -// namespace Dapr.E2E.Test.Workflow; -// -// public class WorkflowTests -// { -// [Fact] -// public async Task ShouldTestTaskChaining() -// { -// var options = new DaprRuntimeOptions(); -// var componentsDir = Path.Combine(Directory.GetCurrentDirectory(), $"test-components-{Guid.NewGuid():N}"); -// -// WebApplication? app = null; -// -// // Build and initialize the test harness -// var harnessBuilder = new DaprHarnessBuilder(options, StartApp); -// var harness = harnessBuilder.BuildWorkflow(componentsDir); -// -// try -// { -// await harness.InitializeAsync(); -// -// await using var scope = app!.Services.CreateAsyncScope(); -// var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); -// -// // Start the workflow -// var workflowId = Guid.NewGuid().ToString("N"); -// const int startingValue = 8; -// -// await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(TestWorkflow), workflowId, startingValue); -// -// var result = await daprWorkflowClient.WaitForWorkflowCompletionAsync(workflowId, true); -// -// Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); -// var resultValue = result.ReadOutputAs(); -// -// Assert.Equal(16, resultValue); -// } -// finally -// { -// await harness.DisposeAsync(); -// if (app is not null) -// await app.DisposeAsync(); -// } -// -// return; -// -// // Define the app startup -// async Task StartApp(int port) -// { -// var builder = WebApplication.CreateBuilder(); -// builder.Logging.ClearProviders(); -// builder.Logging.AddSimpleConsole(); -// builder.WebHost.UseUrls($"http://0.0.0.0:{port}"); -// builder.Services.AddDaprWorkflow(opt => -// { -// opt.RegisterWorkflow(); -// opt.RegisterActivity(); -// }); -// -// Console.WriteLine($"HTTP: {Environment.GetEnvironmentVariable("DAPR_HTTP_ENDPOINT")}"); -// Console.WriteLine($"GRPC: {Environment.GetEnvironmentVariable("DAPR_GRPC_ENDPOINT")}"); -// -// app = builder.Build(); -// await app.StartAsync(); -// } -// } -// -// private sealed class DoublingActivity : WorkflowActivity -// { -// public override Task RunAsync(WorkflowActivityContext context, int input) -// { -// var square = input * 2; -// return Task.FromResult(square); -// } -// } -// -// private sealed class TestWorkflow : Workflow -// { -// public override async Task RunAsync(WorkflowContext context, int input) -// { -// var result = await context.CallActivityAsync(nameof(DoublingActivity), input); -// return result; -// } -// } -// } diff --git a/test/Dapr.E2E.Test/ActorRuntimeChecker.cs b/test/Dapr.E2E.Test/ActorRuntimeChecker.cs index 244e2e8b8..071517561 100644 --- a/test/Dapr.E2E.Test/ActorRuntimeChecker.cs +++ b/test/Dapr.E2E.Test/ActorRuntimeChecker.cs @@ -36,8 +36,8 @@ public static async Task WaitForActorRuntimeAsync(string appId, ITestOutputHelpe } catch (DaprApiException) { - await Task.Delay(TimeSpan.FromMilliseconds(250)); + await Task.Delay(TimeSpan.FromMilliseconds(250), cancellationToken); } } } -} \ No newline at end of file +} diff --git a/test/Dapr.E2E.Test/Actors/E2ETests.WeaklyTypedTests.cs b/test/Dapr.E2E.Test/Actors/E2ETests.WeaklyTypedTests.cs index 22040a15d..c5cc12236 100644 --- a/test/Dapr.E2E.Test/Actors/E2ETests.WeaklyTypedTests.cs +++ b/test/Dapr.E2E.Test/Actors/E2ETests.WeaklyTypedTests.cs @@ -22,7 +22,6 @@ namespace Dapr.E2E.Test; public partial class E2ETests : IAsyncLifetime { -#if NET8_0_OR_GREATER [Fact] public async Task WeaklyTypedActorCanReturnPolymorphicResponse() { @@ -36,21 +35,7 @@ public async Task WeaklyTypedActorCanReturnPolymorphicResponse() result.ShouldBeOfType().DerivedProperty.ShouldNotBeNullOrWhiteSpace(); } -#else - [Fact] - public async Task WeaklyTypedActorCanReturnDerivedResponse() - { - using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(60)); - var pingProxy = this.ProxyFactory.CreateActorProxy(ActorId.CreateRandom(), "WeaklyTypedTestingActor"); - var proxy = this.ProxyFactory.Create(ActorId.CreateRandom(), "WeaklyTypedTestingActor"); - await WaitForActorRuntimeAsync(pingProxy, cts.Token); - - var result = await proxy.InvokeMethodAsync(nameof(IWeaklyTypedTestingActor.GetPolymorphicResponse)); - - result.ShouldBeOfType().DerivedProperty.ShouldNotBeNullOrWhiteSpace(); - } -#endif [Fact] public async Task WeaklyTypedActorCanReturnNullResponse() { @@ -64,4 +49,4 @@ public async Task WeaklyTypedActorCanReturnNullResponse() result.ShouldBeNull(); } -} \ No newline at end of file +} diff --git a/test/Dapr.E2E.Test/DaprCommand.cs b/test/Dapr.E2E.Test/DaprCommand.cs index 52a369341..c52b64c84 100644 --- a/test/Dapr.E2E.Test/DaprCommand.cs +++ b/test/Dapr.E2E.Test/DaprCommand.cs @@ -16,7 +16,6 @@ namespace Dapr.E2E.Test; using System; using System.Collections.Generic; using System.Diagnostics; -using System.Drawing; using System.Linq; using System.Threading; using Xunit.Abstractions; @@ -24,17 +23,17 @@ namespace Dapr.E2E.Test; public class DaprCommand { private readonly ITestOutputHelper output; - private readonly CircularBuffer logBuffer = new CircularBuffer(1000); + private readonly CircularBuffer logBuffer = new(1000); public DaprCommand(ITestOutputHelper output) { this.output = output; } - private EventWaitHandle outputReceived = new EventWaitHandle(false, EventResetMode.ManualReset); + private EventWaitHandle outputReceived = new(false, EventResetMode.ManualReset); public string DaprBinaryName { get; set; } public string Command { get; set; } - public Dictionary EnvironmentVariables { get; set; } = new Dictionary(); + public Dictionary EnvironmentVariables { get; set; } = new(); public string[] OutputToMatch { get; set; } public TimeSpan Timeout { get; set; } @@ -183,4 +182,4 @@ public T[] ToArray() } return result; } -} \ No newline at end of file +} diff --git a/test/Dapr.E2E.Test/DaprTestApp.cs b/test/Dapr.E2E.Test/DaprTestApp.cs index 408dca3b8..bd845f75f 100644 --- a/test/Dapr.E2E.Test/DaprTestApp.cs +++ b/test/Dapr.E2E.Test/DaprTestApp.cs @@ -28,8 +28,8 @@ public class DaprTestApp { static string daprBinaryName = "dapr"; private string appId; - private readonly string[] outputToMatchOnStart = new string[] { "dapr initialized. Status: Running.", }; - private readonly string[] outputToMatchOnStop = new string[] { "app stopped successfully", "failed to stop app id", }; + private readonly string[] outputToMatchOnStart = ["dapr initialized. Status: Running."]; + private readonly string[] outputToMatchOnStop = ["app stopped successfully", "failed to stop app id"]; private ITestOutputHelper testOutput; @@ -63,35 +63,34 @@ public DaprTestApp(ITestOutputHelper output, string appId) if (configuration.UseAppPort) { - arguments.AddRange(new[] { "--app-port", appPort.ToString(CultureInfo.InvariantCulture), }); + arguments.AddRange(["--app-port", appPort.ToString(CultureInfo.InvariantCulture)]); } if (!string.IsNullOrEmpty(configuration.AppProtocol)) { - arguments.AddRange(new[] { "--app-protocol", configuration.AppProtocol }); + arguments.AddRange(["--app-protocol", configuration.AppProtocol]); } - arguments.AddRange(new[] - { + arguments.AddRange([ // separator "--", // `dotnet run` args "dotnet", "run", "--project", configuration.TargetProject, - "--framework", GetTargetFrameworkName(), - }); + "--framework", GetTargetFrameworkName() + ]); if (configuration.UseAppPort) { // The first argument is the port, if the application needs it. - arguments.AddRange(new[] { "--", $"{appPort.ToString(CultureInfo.InvariantCulture)}" }); - arguments.AddRange(new[] { "--urls", $"http://localhost:{appPort.ToString(CultureInfo.InvariantCulture)}", }); + arguments.AddRange(["--", $"{appPort.ToString(CultureInfo.InvariantCulture)}"]); + arguments.AddRange(["--urls", $"http://localhost:{appPort.ToString(CultureInfo.InvariantCulture)}"]); } if (configuration.AppJsonSerialization) { - arguments.AddRange(new[] { "--json-serialization" }); + arguments.AddRange(["--json-serialization"]); } // TODO: we don't do any quoting right now because our paths are guaranteed not to contain spaces @@ -135,8 +134,6 @@ private static string GetTargetFrameworkName() return targetFrameworkName switch { - ".NETCoreApp,Version=v6.0" => "net6", - ".NETCoreApp,Version=v7.0" => "net7", ".NETCoreApp,Version=v8.0" => "net8", ".NETCoreApp,Version=v9.0" => "net9", _ => throw new InvalidOperationException($"Unsupported target framework: {targetFrameworkName}") @@ -164,4 +161,4 @@ private static (int, int, int, int) GetFreePorts() } return (ports[0], ports[1], ports[2], ports[3]); } -} \ No newline at end of file +} diff --git a/test/Dapr.E2E.Test/DaprTestAppFixture.cs b/test/Dapr.E2E.Test/DaprTestAppFixture.cs index 6017d641a..b6d199ef1 100644 --- a/test/Dapr.E2E.Test/DaprTestAppFixture.cs +++ b/test/Dapr.E2E.Test/DaprTestAppFixture.cs @@ -77,7 +77,7 @@ public Task StartAsync(ITestOutputHelper output, DaprRunConfiguration con } } - private State Launch(ITestOutputHelper output, DaprRunConfiguration configuration) + private static State Launch(ITestOutputHelper output, DaprRunConfiguration configuration) { var app = new DaprTestApp(output, configuration.AppId); try @@ -112,4 +112,4 @@ public class State public DaprTestApp App; public ITestOutputHelper Output; } -} \ No newline at end of file +} diff --git a/test/Dapr.E2E.Test/DaprTestAppLifecycle.cs b/test/Dapr.E2E.Test/DaprTestAppLifecycle.cs index 07b62b6a2..1072a54e2 100644 --- a/test/Dapr.E2E.Test/DaprTestAppLifecycle.cs +++ b/test/Dapr.E2E.Test/DaprTestAppLifecycle.cs @@ -60,7 +60,7 @@ public async Task InitializeAsync() return; } - await Task.Delay(TimeSpan.FromMilliseconds(250)); + await Task.Delay(TimeSpan.FromMilliseconds(250), cts.Token); } throw new TimeoutException("Timed out waiting for daprd health check"); @@ -70,4 +70,4 @@ public Task DisposeAsync() { return Task.CompletedTask; } -} \ No newline at end of file +} diff --git a/test/Dapr.E2E.Test/E2ETests.cs b/test/Dapr.E2E.Test/E2ETests.cs index 45974a331..2e9b5bded 100644 --- a/test/Dapr.E2E.Test/E2ETests.cs +++ b/test/Dapr.E2E.Test/E2ETests.cs @@ -80,7 +80,7 @@ public async Task InitializeAsync() return; } - await Task.Delay(TimeSpan.FromMilliseconds(250)); + await Task.Delay(TimeSpan.FromMilliseconds(250), cts.Token); } throw new TimeoutException("Timed out waiting for daprd health check"); @@ -95,4 +95,4 @@ protected async Task WaitForActorRuntimeAsync(IPingActor proxy, CancellationToke { await ActorRuntimeChecker.WaitForActorRuntimeAsync(this.AppId, this.Output, proxy, cancellationToken); } -} \ No newline at end of file +} diff --git a/test/Dapr.E2E.Test.Jobs/Dapr.E2E.Test.Jobs.csproj b/test/Dapr.IntegrationTest.Jobs/Dapr.IntegrationTest.Jobs.csproj similarity index 93% rename from test/Dapr.E2E.Test.Jobs/Dapr.E2E.Test.Jobs.csproj rename to test/Dapr.IntegrationTest.Jobs/Dapr.IntegrationTest.Jobs.csproj index bcbe137eb..c0dc6f60f 100644 --- a/test/Dapr.E2E.Test.Jobs/Dapr.E2E.Test.Jobs.csproj +++ b/test/Dapr.IntegrationTest.Jobs/Dapr.IntegrationTest.Jobs.csproj @@ -4,6 +4,7 @@ enable enable false + Dapr.E2E.Test.Jobs diff --git a/test/Dapr.E2E.Test.Jobs/JobsTests.cs b/test/Dapr.IntegrationTest.Jobs/JobsTests.cs similarity index 91% rename from test/Dapr.E2E.Test.Jobs/JobsTests.cs rename to test/Dapr.IntegrationTest.Jobs/JobsTests.cs index 17e4c3b64..9fe417d3d 100644 --- a/test/Dapr.E2E.Test.Jobs/JobsTests.cs +++ b/test/Dapr.IntegrationTest.Jobs/JobsTests.cs @@ -15,7 +15,6 @@ using Dapr.Jobs; using Dapr.Jobs.Extensions; using Dapr.Jobs.Models; -using Dapr.Jobs.Models.Responses; using Dapr.TestContainers.Common; using Dapr.TestContainers.Common.Options; using Microsoft.Extensions.Configuration; @@ -30,9 +29,12 @@ public sealed class JobsTests public async Task ShouldScheduleAndReceiveJob() { var options = new DaprRuntimeOptions(); - var componentsDir = Path.Combine(Directory.GetCurrentDirectory(), $"jobs-components-{Guid.NewGuid():N}"); + var componentsDir = TestDirectoryManager.CreateTestDirectory("jobs-component"); var jobName = $"e2e-job-{Guid.NewGuid():N}"; - var invocationTcs = new TaskCompletionSource<(string payload, string jobName)>(TaskCreationOptions.RunContinuationsAsynchronously); + + var invocationTcs = + new TaskCompletionSource<(string payload, string jobName)>(TaskCreationOptions + .RunContinuationsAsynchronously); var harness = new DaprHarnessBuilder(options).BuildJobs(componentsDir); await using var testApp = await DaprHarnessBuilder.ForHarness(harness) @@ -44,7 +46,7 @@ public async Task ShouldScheduleAndReceiveJob() var config = sp.GetRequiredService(); var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; var httpEndpoint = config["DAPR_HTTP_ENDPOINT"]; - + if (!string.IsNullOrEmpty(grpcEndpoint)) clientBuilder.UseGrpcEndpoint(grpcEndpoint); if (!string.IsNullOrEmpty(httpEndpoint)) @@ -61,7 +63,7 @@ public async Task ShouldScheduleAndReceiveJob() }); }) .BuildAndStartAsync(); - + // Clean test logic using var scope = testApp.CreateScope(); var daprJobsClient = scope.ServiceProvider.GetRequiredService(); diff --git a/test/Dapr.IntegrationTest.Workflow/AsyncOperationsTests.cs b/test/Dapr.IntegrationTest.Workflow/AsyncOperationsTests.cs new file mode 100644 index 000000000..56f98bb38 --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow/AsyncOperationsTests.cs @@ -0,0 +1,134 @@ +// // ------------------------------------------------------------------------ +// // Copyright 2025 The Dapr Authors +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // http://www.apache.org/licenses/LICENSE-2.0 +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. +// // ------------------------------------------------------------------------ +// +// using Dapr.TestContainers.Common; +// using Dapr.TestContainers.Common.Options; +// using Dapr.Workflow; +// using Microsoft.Extensions.Configuration; +// using Microsoft.Extensions.DependencyInjection; +// +// namespace Dapr.IntegrationTest.Workflow; +// +// public sealed class AsyncOperationsTests +// { +// private const string ProcessingPaymentStatus = "Processing payment..."; +// private const string ContactingWarehouseStatus = "Contacting warehouse..."; +// private const string SuccessStatus = "Success!"; +// +// [Fact] +// public async Task ShouldHandleAsyncOperations() +// { +// var options = new DaprRuntimeOptions(); +// var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-components"); +// var workflowInstanceId = Guid.NewGuid().ToString(); +// +// var harness = new DaprHarnessBuilder(options).BuildWorkflow(componentsDir); +// await using var testApp = await DaprHarnessBuilder.ForHarness(harness) +// .ConfigureServices(builder => +// { +// builder.Services.AddDaprWorkflowBuilder( +// configureRuntime: opt => +// { +// opt.RegisterWorkflow(); +// opt.RegisterActivity(); +// opt.RegisterActivity(); +// }, +// configureClient: (sp, clientBuilder) => +// { +// var config = sp.GetRequiredService(); +// var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; +// if (!string.IsNullOrEmpty(grpcEndpoint)) +// clientBuilder.UseGrpcEndpoint(grpcEndpoint); +// }); +// }) +// .BuildAndStartAsync(); +// +// // Clean test logic +// using var scope = testApp.CreateScope(); +// var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); +// +// // Start the workflow +// var transaction = new Transaction(15.47m); +// await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(TestWorkflow), workflowInstanceId, transaction); +// +// // Wait a second and then get the custom status +// await Task.Delay(TimeSpan.FromSeconds(1)); +// var status1 = await daprWorkflowClient.GetWorkflowStateAsync(workflowInstanceId); +// Assert.NotNull(status1); +// var status1Value = status1?.ReadCustomStatusAs(); +// Assert.Equal(ProcessingPaymentStatus, status1Value); +// +// // The first operation elapses after 5 seconds, so check at a total of 7 seconds +// await Task.Delay(TimeSpan.FromSeconds(6)); +// var status2 = await daprWorkflowClient.GetWorkflowStateAsync(workflowInstanceId); +// Assert.NotNull(status2); +// var status2Value = status2?.ReadCustomStatusAs(); +// Assert.Equal(ContactingWarehouseStatus, status2Value); +// +// // The second operation elapses after 10 seconds, so check at a total of 13 seconds +// await Task.Delay(TimeSpan.FromSeconds(6)); +// var status3 = await daprWorkflowClient.GetWorkflowStateAsync(workflowInstanceId); +// Assert.NotNull(status3); +// var status3Value = status3?.ReadCustomStatusAs(); +// Assert.Equal(SuccessStatus, status3Value); +// } +// +// private sealed record Transaction(decimal Value) +// { +// public Guid CustomerId { get; init; } = Guid.NewGuid(); +// } +// +// private sealed class TestWorkflow : Workflow +// { +// public override async Task RunAsync(WorkflowContext context, Transaction input) +// { +// try +// { +// // Submit the transaction to the payment processor +// context.SetCustomStatus(ProcessingPaymentStatus); +// await context.CallActivityAsync(nameof(ProcessPaymentActivity), input); +// +// // Send the transaction details to the warehouse +// context.SetCustomStatus(ContactingWarehouseStatus); +// await context.CallActivityAsync(nameof(NotifyWarehouseActivity), input); +// +// context.SetCustomStatus(SuccessStatus); +// return true; +// } +// catch +// { +// // If anything goes wrong, return false +// context.SetCustomStatus("Something went wrong!"); +// return false; +// } +// } +// } +// +// private sealed class ProcessPaymentActivity : WorkflowActivity +// { +// public override async Task RunAsync(WorkflowActivityContext context, Transaction input) +// { +// await Task.Delay(TimeSpan.FromSeconds(10)); +// return null; +// } +// } +// +// private sealed class NotifyWarehouseActivity : WorkflowActivity +// { +// public override async Task RunAsync(WorkflowActivityContext context, Transaction input) +// { +// await Task.Delay(TimeSpan.FromSeconds(5)); +// return null; +// } +// } +// } diff --git a/test/Dapr.E2E.Test.PubSub/Dapr.E2E.Test.PubSub.csproj b/test/Dapr.IntegrationTest.Workflow/Dapr.IntegrationTest.Workflow.csproj similarity index 75% rename from test/Dapr.E2E.Test.PubSub/Dapr.E2E.Test.PubSub.csproj rename to test/Dapr.IntegrationTest.Workflow/Dapr.IntegrationTest.Workflow.csproj index 60d871640..7f026e341 100644 --- a/test/Dapr.E2E.Test.PubSub/Dapr.E2E.Test.PubSub.csproj +++ b/test/Dapr.IntegrationTest.Workflow/Dapr.IntegrationTest.Workflow.csproj @@ -7,7 +7,6 @@ - @@ -21,7 +20,10 @@ + + + diff --git a/test/Dapr.IntegrationTest.Workflow/ExternalInputWorkflowTests.cs b/test/Dapr.IntegrationTest.Workflow/ExternalInputWorkflowTests.cs new file mode 100644 index 000000000..5a7f1fe06 --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow/ExternalInputWorkflowTests.cs @@ -0,0 +1,392 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Client; +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using Dapr.Workflow; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Dapr.IntegrationTest.Workflow; + +public sealed partial class ExternalInputWorkflowTests +{ + private List BaseInventory = + [ + new("Paperclips", 5, 100), + new("Cars", 15000, 100), + new("Computers", 500, 100) + ]; + + [Fact] + public async Task ShouldHandleStandardWorkflowsWithDependencyInjection() + { + var options = new DaprRuntimeOptions(); + var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-components"); + var workflowInstanceId = Guid.NewGuid().ToString(); + + var harness = new DaprHarnessBuilder(options).BuildWorkflow(componentsDir); + await using var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + // Register the DaprClient for state management purposes + builder.Services.AddDaprClient((sp, b) => + { + var config = sp.GetRequiredService(); + var httpEndpoint = config["DAPR_HTTP_ENDPOINT"]; + if (!string.IsNullOrEmpty(httpEndpoint)) + b.UseHttpEndpoint(httpEndpoint); + var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; + if (!string.IsNullOrEmpty(grpcEndpoint)) + b.UseGrpcEndpoint(grpcEndpoint); + }); + + // Register the Dapr Workflow client + builder.Services.AddDaprWorkflowBuilder( + configureRuntime: opt => + { + opt.RegisterWorkflow(); + opt.RegisterActivity(); + opt.RegisterActivity(); + opt.RegisterActivity(); + opt.RegisterActivity(); + opt.RegisterActivity(); + }, + configureClient: (sp, clientBuilder) => + { + var config = sp.GetRequiredService(); + var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; + if (!string.IsNullOrEmpty(grpcEndpoint)) + clientBuilder.UseGrpcEndpoint(grpcEndpoint); + }); + }) + .BuildAndStartAsync(); + + // Clean test logic + using var scope = testApp.CreateScope(); + + // Set up the base inventory in the Dapr state management store + var daprClient = scope.ServiceProvider.GetRequiredService(); + foreach (var baseInventoryItem in BaseInventory) + { + await daprClient.SaveStateAsync(TestContainers.Constants.DaprComponentNames.StateManagementComponentName, + baseInventoryItem.Name.ToLowerInvariant(), baseInventoryItem); + } + + var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); + + // Create an order under the threshold + const string itemName = "Computers"; + const int amount = 3; + var item = BaseInventory.First(item => + string.Equals(item.Name, itemName, StringComparison.OrdinalIgnoreCase)); + var totalCost = amount * item.PerItemCost; + var orderInfo = new OrderPayload(itemName.ToLowerInvariant(), totalCost, amount); + + // Start the workflow + await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), workflowInstanceId, + orderInfo); + + + // Wait for the workflow to complete - it shouldn't ask for approval + var result = await daprWorkflowClient.WaitForWorkflowCompletionAsync(workflowInstanceId); + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + var resultValue = result.ReadOutputAs(); + Assert.NotNull(resultValue); + Assert.True(resultValue.Processed); + } + + public sealed record Notification(string Message); + + public sealed record OrderPayload(string Name, double TotalCost, int Quantity); + + public sealed record InventoryRequest(string RequestId, string ItemName, int Quantity); + + public sealed record InventoryResult(bool Success, InventoryItem? Item); + + public sealed record PaymentRequest(string RequestId, string ItemName, int Amount, double Currency); + public sealed record InventoryItem(string Name, double PerItemCost, int Quantity); + public sealed record OrderResult(bool Processed); + public enum ApprovalResult + { + Unspecified = 0, + Approved = 1, + Rejected = 2 + } + + internal sealed partial class OrderProcessingWorkflow : Workflow + { + readonly WorkflowTaskOptions defaultActivityRetryOptions = new() + { + // NOTE: Beware that changing the number of retries is a breaking change for existing workflows. + RetryPolicy = new WorkflowRetryPolicy( + maxNumberOfAttempts: 3, + firstRetryInterval: TimeSpan.FromSeconds(5)), + }; + + public override async Task RunAsync(WorkflowContext context, OrderPayload order) + { + var orderId = context.InstanceId; + var logger = context.CreateReplaySafeLogger(); + + LogReceivedOrder(logger, orderId, order.Quantity, order.Name, order.TotalCost); + + // Notify the user that an order has come through + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification($"Received order {orderId} for {order.Quantity} {order.Name} at ${order.TotalCost}")); + + // Determine if there is enough of the item available for purchase by checking the inventory + InventoryResult result = await context.CallActivityAsync( + nameof(ReserveInventoryActivity), + new InventoryRequest(RequestId: orderId, order.Name, order.Quantity), + this.defaultActivityRetryOptions); + + // If there is insufficient inventory, fail and let the user know + if (!result.Success) + { + LogInsufficientInventory(logger, order.Name); + + // End the workflow here since we don't have sufficient inventory + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification($"Insufficient inventory for {order.Name}")); + return new OrderResult(Processed: false); + } + + // Require orders over a certain threshold to be approved + const int threshold = 50000; + if (order.TotalCost > threshold) + { + LogRequestingApproval(logger, order.TotalCost, threshold); + // Request manager approval for the order + await context.CallActivityAsync(nameof(RequestApprovalActivity), order); + + try + { + // Pause and wait for a manager to approve the order + context.SetCustomStatus("Waiting for approval"); + ApprovalResult approvalResult = await context.WaitForExternalEventAsync( + eventName: "ManagerApproval", + timeout: TimeSpan.FromSeconds(30)); + + LogApprovalResult(logger, approvalResult); + context.SetCustomStatus($"Approval result: {approvalResult}"); + if (approvalResult == ApprovalResult.Rejected) + { + logger.LogWarning("Order was rejected by approver"); + + // The order was rejected, end the workflow here + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification($"Order was rejected by approver")); + return new OrderResult(Processed: false); + } + } + catch (TaskCanceledException) + { + LogCancelingOrder(logger); + + // An approval timeout results in automatic order cancellation + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification($"Cancelling order because it didn't receive an approval")); + return new OrderResult(Processed: false); + } + } + + // There is enough inventory available so the user can purchase the item(s). Process their payment + LogInsufficientInventory(logger, order.Name); + await context.CallActivityAsync( + nameof(ProcessPaymentActivity), + new PaymentRequest(RequestId: orderId, order.Name, order.Quantity, order.TotalCost), + this.defaultActivityRetryOptions); + + try + { + // There is enough inventory available so the user can purchase the item(s). Process their payment + + await context.CallActivityAsync( + nameof(UpdateInventoryActivity), + new PaymentRequest(RequestId: orderId, order.Name, order.Quantity, order.TotalCost), + this.defaultActivityRetryOptions); + } + catch (WorkflowTaskFailedException e) + { + // Let them know their payment processing failed + LogOrderFailed(logger, orderId, e.FailureDetails.ErrorMessage); + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification($"Order {orderId} Failed! Details: {e.FailureDetails.ErrorMessage}")); + return new OrderResult(Processed: false); + } + + // Let them know their payment was processed + LogOrderCompleted(logger, orderId); + await context.CallActivityAsync( + nameof(NotifyActivity), + new Notification($"Order {orderId} has completed!")); + + // End the workflow with a success result + return new OrderResult(Processed: true); + } + + [LoggerMessage(LogLevel.Information, "Received order '{OrderId}' for {Quantity} of {ItemName} at ${TotalCost}")] + private static partial void LogReceivedOrder(ILogger logger, string orderId, int quantity, string itemName, double totalCost); + + [LoggerMessage(LogLevel.Error, "Insufficient inventory for '{OrderName}'")] + private static partial void LogInsufficientInventory(ILogger logger, string orderName); + + [LoggerMessage(LogLevel.Information, "Requesting manager approval since total cost {TotalCost} exceeds threshold {Threshold}")] + private static partial void LogRequestingApproval(ILogger logger, double totalCost, double threshold); + + [LoggerMessage(LogLevel.Information, "Approval result: {ApprovalResult}")] + private static partial void LogApprovalResult(ILogger logger, ApprovalResult approvalResult); + + [LoggerMessage(LogLevel.Information, "Processing payment as sufficient inventory is available")] + private static partial void LogProcessingPayment(ILogger logger); + + [LoggerMessage(LogLevel.Error, "Cancelling order because it didn't receive an approval")] + private static partial void LogCancelingOrder(ILogger logger); + + [LoggerMessage(LogLevel.Error, "Order {OrderId} failed! Details: {ErrorMessage}")] + private static partial void LogOrderFailed(ILogger logger, string orderId, string errorMessage); + + [LoggerMessage(LogLevel.Information, "Order {OrderId} has completed!")] + private static partial void LogOrderCompleted(ILogger logger, string orderId); + } + + public sealed partial class UpdateInventoryActivity(ILogger logger, DaprClient daprClient) : WorkflowActivity + { + public override async Task RunAsync(WorkflowActivityContext context, PaymentRequest request) + { + LogInventoryCheck(request.RequestId, request.Amount, request.ItemName); + + // Simulate slow processing + await Task.Delay((TimeSpan.FromSeconds(5))); + + // Determine if there are enough items for purchase + var item = await daprClient.GetStateAsync(TestContainers.Constants.DaprComponentNames.StateManagementComponentName, request.ItemName.ToLowerInvariant()); + var newQuantity = item.Quantity - request.Amount; + if (newQuantity < 0) + { + LogInsufficientInventory(request.RequestId, request.Amount, item.Quantity, request.ItemName); + throw new InvalidOperationException($"Not enough '{request.ItemName}' inventory! Requested {request.Amount} but only {item.Quantity} available."); + } + + // Update the state store with the new amount of the item + await daprClient.SaveStateAsync(TestContainers.Constants.DaprComponentNames.StateManagementComponentName, request.ItemName.ToLowerInvariant(), new InventoryItem(request.ItemName, item.PerItemCost, newQuantity)); + + LogUpdatedInventory(newQuantity, item.Name); + return null; + } + + [LoggerMessage(LogLevel.Information, "Checking inventory for order '{RequestId}' for {Amount} {ItemName}")] + private partial void LogInventoryCheck(string requestId, int amount, string itemName); + + [LoggerMessage(LogLevel.Warning, + "Payment for request ID '{RequestId}' could not be processed. Requested {RequestedAmount} and only have {AvailableAmount} available of {ItemName}")] + private partial void LogInsufficientInventory(string requestId, int requestedAmount, int availableAmount, + string itemName); + + [LoggerMessage(LogLevel.Information, "There are not {Quantity} of {ItemName} left in stock")] + private partial void LogUpdatedInventory(int quantity, string itemName); + } + + public sealed partial class ProcessPaymentActivity(ILogger logger) + : WorkflowActivity + { + public override async Task RunAsync(WorkflowActivityContext context, PaymentRequest input) + { + LogProcessing(input.RequestId, input.Amount, input.ItemName, input.Currency); + + // Simulate slow processing + await Task.Delay(TimeSpan.FromSeconds(7)); + + LogProcessingSuccessful(input.RequestId); + return null; + } + + [LoggerMessage(LogLevel.Information, "Processing payment for order {RequestId} for {Amount} of {ItemName} at ${Currency}")] + private partial void LogProcessing(string requestId, double amount, string itemName, double currency); + + [LoggerMessage(LogLevel.Information, "Payment for request ID '{RequestId}' processed successfully")] + private partial void LogProcessingSuccessful(string requestId); + } + + public sealed partial class ReserveInventoryActivity(ILogger logger, DaprClient daprClient) + : WorkflowActivity + { + public override async Task RunAsync(WorkflowActivityContext context, InventoryRequest req) + { + LogReservation(req.RequestId, req.Quantity, req.ItemName); + + // Ensure that the store has items + var item = await daprClient.GetStateAsync(TestContainers.Constants.DaprComponentNames.StateManagementComponentName, req.ItemName.ToLowerInvariant()); + + // Catch for the case where the statestore isn't set up + if (item == null) + { + // Not enough items + return new InventoryResult(false, item); + } + + LogAvailability(item.Quantity, item.Name); + + // See if there are enough items to purchase + if (item.Quantity >= req.Quantity) + { + // Simulate slow processing + await Task.Delay(TimeSpan.FromSeconds(2)); + return new InventoryResult(true, item); + } + + // Not enough items + return new InventoryResult(false, item); + } + + [LoggerMessage(LogLevel.Information, "Reserving inventory for order '{RequestId}' of {Quantity} {ItemName}")] + private partial void LogReservation(string requestId, int quantity, string itemName); + + [LoggerMessage(LogLevel.Information, "There are {Quantity} {ItemName} available for purchase")] + private partial void LogAvailability(int Quantity, string ItemName); + } + + public sealed partial class RequestApprovalActivity(ILogger logger) + : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, OrderPayload input) + { + var orderId = context.InstanceId; + LogApprovalRequest(orderId); + return Task.FromResult(null); + } + + [LoggerMessage(LogLevel.Information, "Requesting approval for order {Orderid}" )] + private partial void LogApprovalRequest(string orderId); + } + + public sealed partial class NotifyActivity(ILogger logger) : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, Notification input) + { + LogNotification(input.Message); + return Task.FromResult(null); + } + + [LoggerMessage(LogLevel.Information, "A notification message was surfaced: '{Message}'")] + private partial void LogNotification(string Message); + } +} diff --git a/test/Dapr.IntegrationTest.Workflow/FanOutFanInTests.cs b/test/Dapr.IntegrationTest.Workflow/FanOutFanInTests.cs new file mode 100644 index 000000000..1baf2385c --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow/FanOutFanInTests.cs @@ -0,0 +1,85 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using Dapr.Workflow; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Workflow; + +public sealed class FanOutFanInTests +{ + private const string CompletedMessage = "Workflow completed!"; + + [Fact] + public async Task ShouldFanOutAndFanIn() + { + var options = new DaprRuntimeOptions(); + var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-components"); + var workflowInstanceId = Guid.NewGuid().ToString(); + + var harness = new DaprHarnessBuilder(options).BuildWorkflow(componentsDir); + await using var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddDaprWorkflowBuilder( + configureRuntime: opt => + { + opt.RegisterWorkflow(); + opt.RegisterActivity(); + }, + configureClient: (sp, clientBuilder) => + { + var config = sp.GetRequiredService(); + var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; + if (!string.IsNullOrEmpty(grpcEndpoint)) + clientBuilder.UseGrpcEndpoint(grpcEndpoint); + }); + }) + .BuildAndStartAsync(); + + using var scope = testApp.CreateScope(); + var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); + + await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(TestWorkflow), workflowInstanceId, "test input"); + var result = await daprWorkflowClient.WaitForWorkflowCompletionAsync(workflowInstanceId); + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + + var resultValue = result.ReadOutputAs(); + Assert.Equal(CompletedMessage, resultValue); + } + + private sealed class TestWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, string input) + { + var tasks = new List(); + for (var a = 1; a <= 3; a++) + { + var task = context.CallActivityAsync(nameof(NotifyActivity), $"calling task {a}"); + tasks.Add(task); + } + + await Task.WhenAll(tasks); + return CompletedMessage; + } + } + + private sealed class NotifyActivity: WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, string input) => + Task.FromResult(null); + } +} diff --git a/test/Dapr.IntegrationTest.Workflow/SimpleWorkflowTests.cs b/test/Dapr.IntegrationTest.Workflow/SimpleWorkflowTests.cs new file mode 100644 index 000000000..fc1b3e1bb --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow/SimpleWorkflowTests.cs @@ -0,0 +1,83 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using Dapr.Workflow; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Workflow; + +public sealed class SimpleWorkflowTests +{ + [Fact] + public async Task ShouldHandleSimpleWorkflow() + { + var options = new DaprRuntimeOptions(); + var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-components"); + var workflowInstanceId = Guid.NewGuid().ToString(); + + var harness = new DaprHarnessBuilder(options).BuildWorkflow(componentsDir); + await using var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddDaprWorkflowBuilder( + configureRuntime: opt => + { + opt.RegisterWorkflow(); + opt.RegisterActivity(); + }, + configureClient: (sp, clientBuilder) => + { + var config = sp.GetRequiredService(); + var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; + if (!string.IsNullOrEmpty(grpcEndpoint)) + clientBuilder.UseGrpcEndpoint(grpcEndpoint); + }); + }) + .BuildAndStartAsync(); + + // Clean test logic + using var scope = testApp.CreateScope(); + var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); + + // Start the workflow + const int startingValue = 8; + + await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(TestWorkflow), workflowInstanceId, startingValue); + var result = await daprWorkflowClient.WaitForWorkflowCompletionAsync(workflowInstanceId, true); + + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + var resultValue = result.ReadOutputAs(); + Assert.Equal(16, resultValue); + } + + private sealed class DoublingActivity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, int input) + { + var square = input * 2; + return Task.FromResult(square); + } + } + + private sealed class TestWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, int input) + { + var result = await context.CallActivityAsync(nameof(DoublingActivity), input); + return result; + } + } +} diff --git a/test/Dapr.IntegrationTest.Workflow/SubworkflowTests.cs b/test/Dapr.IntegrationTest.Workflow/SubworkflowTests.cs new file mode 100644 index 000000000..cf6ffe6d4 --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow/SubworkflowTests.cs @@ -0,0 +1,86 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using Dapr.Workflow; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Workflow; + +public sealed class SubworkflowTests +{ + [Fact] + public async Task ShouldHandleSubworkflow() + { + var options = new DaprRuntimeOptions(); + var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-components"); + var workflowInstanceId = Guid.NewGuid().ToString(); + + var harness = new DaprHarnessBuilder(options).BuildWorkflow(componentsDir); + await using var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddDaprWorkflowBuilder( + configureRuntime: opt => + { + opt.RegisterWorkflow(); + opt.RegisterWorkflow(); + }, + configureClient: (sp, clientBuilder) => + { + var config = sp.GetRequiredService(); + var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; + if (!string.IsNullOrWhiteSpace(grpcEndpoint)) + clientBuilder.UseGrpcEndpoint(grpcEndpoint); + }); + }) + .BuildAndStartAsync(); + + using var scope = testApp.CreateScope(); + var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); + + await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(DemoWorkflow), workflowInstanceId, workflowInstanceId); + + var workflowResult = await daprWorkflowClient.WaitForWorkflowCompletionAsync(workflowInstanceId); + Assert.Equal(WorkflowRuntimeStatus.Completed, workflowResult.RuntimeStatus); + var workflowResultValue = workflowResult.ReadOutputAs(); + Assert.True(workflowResultValue); + + var subworkflowResult = await daprWorkflowClient.WaitForWorkflowCompletionAsync($"{workflowInstanceId}-sub"); + Assert.Equal(WorkflowRuntimeStatus.Completed, workflowResult.RuntimeStatus); + var subworkflowResultValue = subworkflowResult.ReadOutputAs(); + Assert.True(subworkflowResultValue); + } + + private sealed class DemoWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, string instanceId) + { + var subInstanceId = $"{instanceId}-sub"; + var options = new ChildWorkflowTaskOptions(subInstanceId); + await context.CallChildWorkflowAsync(nameof(DemoSubWorkflow), "Hello, sub-workflow", options); + return true; + } + } + + private sealed class DemoSubWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, string input) + { + await context.CreateTimer(TimeSpan.FromSeconds(5)); + return true; + } + } +} diff --git a/test/Dapr.IntegrationTest.Workflow/TaskChainingWorkflowTests.cs b/test/Dapr.IntegrationTest.Workflow/TaskChainingWorkflowTests.cs new file mode 100644 index 000000000..aa9f1232a --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow/TaskChainingWorkflowTests.cs @@ -0,0 +1,105 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using Dapr.Workflow; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Workflow; + +public sealed class TaskChainingWorkflowTests +{ + private static readonly int[] expected = [43, 45, 90]; + + [Fact] + public async Task ShouldHandleTaskChaining() + { + var options = new DaprRuntimeOptions(); + var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-components"); + var workflowInstanceId = Guid.NewGuid().ToString(); + + var harness = new DaprHarnessBuilder(options).BuildWorkflow(componentsDir); + await using var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddDaprWorkflowBuilder( + configureRuntime: opt => + { + opt.RegisterWorkflow(); + opt.RegisterActivity(); + opt.RegisterActivity(); + opt.RegisterActivity(); + }, configureClient: (sp, clientBuilder) => + { + var config = sp.GetRequiredService(); + var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; + if (!string.IsNullOrEmpty(grpcEndpoint)) + clientBuilder.UseGrpcEndpoint(grpcEndpoint); + }); + }) + .BuildAndStartAsync(); + + using var scope = testApp.CreateScope(); + var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); + + // Start the workflow + const int startingValue = 42; + + await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(TestWorkflow), workflowInstanceId, startingValue); + var result = await daprWorkflowClient.WaitForWorkflowCompletionAsync(workflowInstanceId); + + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + var resultValue = result.ReadOutputAs() ?? []; + Assert.Equal(expected, resultValue); + } + + private sealed class TestWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, int input) + { + var result1 = await context.CallActivityAsync(nameof(Step1Activity), input); + var result2 = await context.CallActivityAsync(nameof(Step2Activity), result1); + var result3 = await context.CallActivityAsync(nameof(Step3Activity), result2); + var ret = new[] { result1, result2, result3 }; + + return ret; + } + } + + private sealed class Step1Activity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, int input) + { + return Task.FromResult(input + 1); + } + } + + private sealed class Step2Activity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, int input) + { + return Task.FromResult(input + 2); + } + } + + private sealed class Step3Activity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, int input) + { + return Task.FromResult(input * 2); + } + } +} + diff --git a/test/Dapr.IntegrationTest.Workflow/TaskExecutionKeyTests.cs b/test/Dapr.IntegrationTest.Workflow/TaskExecutionKeyTests.cs new file mode 100644 index 000000000..b1e99ff4b --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow/TaskExecutionKeyTests.cs @@ -0,0 +1,86 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using Dapr.Workflow; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Workflow; + +public sealed class TaskExecutionKeyTests +{ + [Fact] + public async Task ActivityContext_ShouldContainTaskExecutionKey() + { + var options = new DaprRuntimeOptions(); + var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-components"); + var workflowInstanceId = Guid.NewGuid().ToString(); + + var harness = new DaprHarnessBuilder(options).BuildWorkflow(componentsDir); + await using var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddDaprWorkflowBuilder( + configureRuntime: opt => + { + opt.RegisterWorkflow(); + opt.RegisterActivity(); + }, + configureClient: (sp, clientBuilder) => + { + var config = sp.GetRequiredService(); + var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; + if (!string.IsNullOrEmpty(grpcEndpoint)) + clientBuilder.UseGrpcEndpoint(grpcEndpoint); + }); + }) + .BuildAndStartAsync(); + + // Clean test logic + using var scope = testApp.CreateScope(); + var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); + + // Start the workflow + await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(GetTaskExecutionKeyWorkflow), workflowInstanceId, "start"); + var result = await daprWorkflowClient.WaitForWorkflowCompletionAsync(workflowInstanceId, true); + + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + + // Retrieve the task execution key returned by the activity + var taskExecutionKey = result.ReadOutputAs(); + + // Assert that the TaskExecutionKey is present + Assert.False(string.IsNullOrWhiteSpace(taskExecutionKey), "TaskExecutionKey should not be null or empty."); + } + + private sealed class GetTaskExecutionKeyActivity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, string input) + { + // Verify we can access the TaskExecutionKey from the context + return Task.FromResult(context.TaskExecutionKey); + } + } + + private sealed class GetTaskExecutionKeyWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, string input) + { + // Call the activity and return its result (the TaskExecutionKey) + var result = await context.CallActivityAsync(nameof(GetTaskExecutionKeyActivity), input); + return result; + } + } +} diff --git a/test/Dapr.IntegrationTest.Workflow/WorkflowMonitorTests.cs b/test/Dapr.IntegrationTest.Workflow/WorkflowMonitorTests.cs new file mode 100644 index 000000000..ad851913b --- /dev/null +++ b/test/Dapr.IntegrationTest.Workflow/WorkflowMonitorTests.cs @@ -0,0 +1,110 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.TestContainers.Common; +using Dapr.TestContainers.Common.Options; +using Dapr.Workflow; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; + +namespace Dapr.IntegrationTest.Workflow; + +public sealed class WorkflowMonitorTests +{ + [Fact] + public async Task ShouldHandleContinueAsNew() + { + var options = new DaprRuntimeOptions(); + var componentsDir = TestDirectoryManager.CreateTestDirectory("workflow-components"); + var workflowInstanceId = Guid.NewGuid().ToString(); + + var harness = new DaprHarnessBuilder(options).BuildWorkflow(componentsDir); + await using var testApp = await DaprHarnessBuilder.ForHarness(harness) + .ConfigureServices(builder => + { + builder.Services.AddDaprWorkflowBuilder( + configureRuntime: opt => + { + opt.RegisterWorkflow(); + opt.RegisterActivity(); + }, + configureClient: (sp, clientBuilder) => + { + var config = sp.GetRequiredService(); + var grpcEndpoint = config["DAPR_GRPC_ENDPOINT"]; + if (!string.IsNullOrEmpty(grpcEndpoint)) + clientBuilder.UseGrpcEndpoint(grpcEndpoint); + }); + }) + .BuildAndStartAsync(); + + // Clean test logic + using var scope = testApp.CreateScope(); + var daprWorkflowClient = scope.ServiceProvider.GetRequiredService(); + + var invocationTimeout = new CancellationTokenSource(TimeSpan.FromMinutes(3)); + await daprWorkflowClient.ScheduleNewWorkflowAsync(nameof(DemoWorkflow), workflowInstanceId, new HealthRecord(true)); + var result = await daprWorkflowClient.WaitForWorkflowCompletionAsync(workflowInstanceId, cancellation: invocationTimeout.Token); + Assert.False(invocationTimeout.IsCancellationRequested); + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + var resultValue = result.ReadOutputAs(); + Assert.Equal(3, resultValue); + } + + private sealed class DemoWorkflow : Workflow + { + public override async Task RunAsync(WorkflowContext context, HealthRecord input) + { + var status = await context.CallActivityAsync(nameof(CheckStatusActivity), true); + int nextSleepInterval; + if (!context.IsReplaying) + { + Console.WriteLine($"This job is {status}"); + } + + if (status == "healthy") + { + input.IsHealthy = true; + nextSleepInterval = 30; + } + else + { + if (input.IsHealthy) + { + input.IsHealthy = false; + } + Console.WriteLine("Status is unhealthy. Set check interval to 5s"); + nextSleepInterval = 5; + } + + // If this is the third such interval, call it quits + if (input.CurrentInterval >= 3) + return input.CurrentInterval; + + await context.CreateTimer(TimeSpan.FromSeconds(nextSleepInterval)); + input = input with {CurrentInterval = input.CurrentInterval + 1}; + context.ContinueAsNew(input); + return input.CurrentInterval; + } + } + + private record struct HealthRecord(bool IsHealthy, int CurrentInterval = 0); + + private sealed class CheckStatusActivity : WorkflowActivity + { + private static List _status = ["healthy", "unhealthy"]; + private readonly Random random = new(); + + public override Task RunAsync(WorkflowActivityContext context, bool input) => Task.FromResult(_status[random.Next(_status.Count)]); + } +} diff --git a/test/Dapr.E2E.Test.Workflow/Dapr.E2E.Test.Workflow.csproj b/test/Dapr.Workflow.Abstractions.Test/Dapr.Workflow.Abstractions.Test.csproj similarity index 70% rename from test/Dapr.E2E.Test.Workflow/Dapr.E2E.Test.Workflow.csproj rename to test/Dapr.Workflow.Abstractions.Test/Dapr.Workflow.Abstractions.Test.csproj index fc4a8b0db..8162cd444 100644 --- a/test/Dapr.E2E.Test.Workflow/Dapr.E2E.Test.Workflow.csproj +++ b/test/Dapr.Workflow.Abstractions.Test/Dapr.Workflow.Abstractions.Test.csproj @@ -10,7 +10,6 @@ - @@ -19,8 +18,7 @@ - - + diff --git a/test/Dapr.Workflow.Abstractions.Test/IWorkflowActivityTests.cs b/test/Dapr.Workflow.Abstractions.Test/IWorkflowActivityTests.cs new file mode 100644 index 000000000..3a3f3f159 --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/IWorkflowActivityTests.cs @@ -0,0 +1,36 @@ +namespace Dapr.Workflow.Abstractions.Test; + +public class IWorkflowActivityTests +{ + private sealed class EchoActivity : IWorkflowActivity + { + public Type InputType => typeof(string); + public Type OutputType => typeof(string); + + public Task RunAsync(WorkflowActivityContext context, object? input) + { + // Prefix with instance id and identifier to ensure we used context + var prefix = $"{context.InstanceId}:{context.Identifier.Name}:"; + return Task.FromResult(prefix + (input as string)); + } + } + + private sealed class Ctx(string id, string instance) : WorkflowActivityContext + { + public override TaskIdentifier Identifier { get; } = new TaskIdentifier(id); + public override string InstanceId { get; } = instance; + public override string TaskExecutionKey { get; } = "exec-ctx-1"; + } + + [Fact] + public async Task Activity_Reports_Types_And_Runs() + { + var act = new EchoActivity(); + Assert.Equal(typeof(string), act.InputType); + Assert.Equal(typeof(string), act.OutputType); + + var ctx = new Ctx("echo", "wf-1"); + var result = await act.RunAsync(ctx, "hi"); + Assert.Equal("wf-1:echo:hi", result); + } +} diff --git a/test/Dapr.Workflow.Abstractions.Test/TaskIdentifierTests.cs b/test/Dapr.Workflow.Abstractions.Test/TaskIdentifierTests.cs new file mode 100644 index 000000000..dc40782c2 --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/TaskIdentifierTests.cs @@ -0,0 +1,47 @@ +namespace Dapr.Workflow.Abstractions.Test; + +public class TaskIdentifierTests +{ + [Fact] + public void Implicit_String_To_TaskIdentifier_Works() + { + TaskIdentifier id = "my-task"; + Assert.Equal("my-task", id.Name); + } + + [Fact] + public void Implicit_TaskIdentifier_To_String_Works() + { + var id = new TaskIdentifier("activity-1"); + string s = id; + Assert.Equal("activity-1", s); + } + + [Fact] + public void ToString_Returns_Name() + { + var id = new TaskIdentifier("xyz"); + Assert.Equal("xyz", id.ToString()); + } + + [Fact] + public void Equality_By_Name() + { + var a = new TaskIdentifier("same"); + var b = new TaskIdentifier("same"); + var c = new TaskIdentifier("other"); + + Assert.Equal(a, b); + Assert.True(a == b); + Assert.NotEqual(a, c); + Assert.True(a != c); + } + + [Fact] + public void Default_TaskIdentifier_Has_Null_Name() + { + TaskIdentifier d = default; + Assert.Null(d.Name); + Assert.Null(((string)d)); + } +} diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowActivityAttributeTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowActivityAttributeTests.cs new file mode 100644 index 000000000..8d3064e3e --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowActivityAttributeTests.cs @@ -0,0 +1,31 @@ +using System.Reflection; +using Dapr.Workflow.Abstractions.Attributes; + +namespace Dapr.Workflow.Abstractions.Test; + +public class WorkflowActivityAttributeTests +{ + [Fact] + public void DefaultCtor_Sets_Name_Null() + { + var attr = new WorkflowActivityAttribute(); + Assert.Null(attr.Name); + } + + [Fact] + public void NamedCtor_Sets_Name() + { + var attr = new WorkflowActivityAttribute("MyActivity"); + Assert.Equal("MyActivity", attr.Name); + } + + [Fact] + public void AttributeUsage_Is_Class_Only_NotInherited_NotMultiple() + { + var usage = typeof(WorkflowActivityAttribute).GetCustomAttribute(); + Assert.NotNull(usage); + Assert.Equal(AttributeTargets.Class, usage!.ValidOn); + Assert.False(usage.AllowMultiple); + Assert.False(usage.Inherited); + } +} diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowActivityContextTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowActivityContextTests.cs new file mode 100644 index 000000000..c7abefd01 --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowActivityContextTests.cs @@ -0,0 +1,31 @@ +namespace Dapr.Workflow.Abstractions.Test; + +public class WorkflowActivityContextTests +{ + private sealed class TestContext : WorkflowActivityContext + { + private readonly TaskIdentifier _id; + private readonly string _instanceId; + private readonly string _taskExecutionKey; + + public TestContext(TaskIdentifier id, string instanceId, string taskExecutionKey = "exec-1") + { + _id = id; + _instanceId = instanceId; + _taskExecutionKey = taskExecutionKey; + } + + public override TaskIdentifier Identifier => _id; + public override string InstanceId => _instanceId; + public override string TaskExecutionKey => _taskExecutionKey; + } + + [Fact] + public void Properties_Return_Constructor_Values() + { + var ctx = new TestContext(new TaskIdentifier("act-1"), "wf-123", "run-42"); + Assert.Equal("act-1", ctx.Identifier.Name); + Assert.Equal("wf-123", ctx.InstanceId); + Assert.Equal("run-42", ctx.TaskExecutionKey); + } +} diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowActivityTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowActivityTests.cs new file mode 100644 index 000000000..015f506dd --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowActivityTests.cs @@ -0,0 +1,34 @@ +using Dapr.Workflow; +using Dapr.Workflow.Abstractions; + +namespace Dapr.Workflow.Abstractions.Test; + +public class WorkflowActivityTests +{ + private sealed class LengthActivity : WorkflowActivity + { + public override Task RunAsync(WorkflowActivityContext context, string input) + => Task.FromResult(input?.Length ?? 0); + } + + private sealed class TestActivityContext : WorkflowActivityContext + { + public override TaskIdentifier Identifier => new("len"); + public override string InstanceId => "instance-1"; + public override string TaskExecutionKey => "exec-len-1"; + } + + [Fact] + public async Task Generic_Base_Exposes_IWorkflowActivity_Types_And_Runs() + { + var activity = new LengthActivity(); + var i = (IWorkflowActivity)activity; + + Assert.Equal(typeof(string), i.InputType); + Assert.Equal(typeof(int), i.OutputType); + + var ctx = new TestActivityContext(); + var result = await i.RunAsync(ctx, "abc"); + Assert.Equal(3, (int)result!); + } +} diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowAttributeTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowAttributeTests.cs new file mode 100644 index 000000000..864659885 --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowAttributeTests.cs @@ -0,0 +1,31 @@ +using System.Reflection; +using Dapr.Workflow.Abstractions.Attributes; + +namespace Dapr.Workflow.Abstractions.Test; + +public class WorkflowAttributeTests +{ + [Fact] + public void DefaultCtor_Sets_Name_Null() + { + var attr = new WorkflowAttribute(); + Assert.Null(attr.Name); + } + + [Fact] + public void NamedCtor_Sets_Name() + { + var attr = new WorkflowAttribute("MyWorkflow"); + Assert.Equal("MyWorkflow", attr.Name); + } + + [Fact] + public void AttributeUsage_Is_Class_Only_NotInherited_NotMultiple() + { + var usage = typeof(WorkflowAttribute).GetCustomAttribute(); + Assert.NotNull(usage); + Assert.Equal(AttributeTargets.Class, usage!.ValidOn); + Assert.False(usage.AllowMultiple); + Assert.False(usage.Inherited); + } +} diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowContextDelegationTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowContextDelegationTests.cs new file mode 100644 index 000000000..dae378918 --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowContextDelegationTests.cs @@ -0,0 +1,102 @@ +using Dapr.Workflow; + +namespace Dapr.Workflow.Abstractions.Test; + +public class WorkflowContextDelegationTests +{ + private sealed class ProbeContext : WorkflowContext + { + public override string Name => "name"; + public override string InstanceId => "inst-1"; + public override DateTime CurrentUtcDateTime => _now; + public override bool IsReplaying => false; + + private DateTime _now = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + public string? LastActivityName { get; private set; } + public object? LastActivityInput { get; private set; } + public WorkflowTaskOptions? LastActivityOptions { get; private set; } + + public DateTime? LastTimerFireAt { get; private set; } + public CancellationToken LastTimerToken { get; private set; } + + public string? LastChildName { get; private set; } + public object? LastChildInput { get; private set; } + public ChildWorkflowTaskOptions? LastChildOptions { get; private set; } + + public override Task CallActivityAsync(string name, object? input = null, WorkflowTaskOptions? options = null) + { + LastActivityName = name; + LastActivityInput = input; + LastActivityOptions = options; + return Task.FromResult(default(T)!); + } + + public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken) + { + LastTimerFireAt = fireAt; + LastTimerToken = cancellationToken; + return Task.CompletedTask; + } + + public override Task WaitForExternalEventAsync(string eventName, CancellationToken cancellationToken = default) => Task.FromResult(default(T)!); + public override Task WaitForExternalEventAsync(string eventName, TimeSpan timeout) => Task.FromResult(default(T)!); + public override void SendEvent(string instanceId, string eventName, object payload) { } + public override void SetCustomStatus(object? customStatus) { } + + public override Task CallChildWorkflowAsync(string workflowName, object? input = null, ChildWorkflowTaskOptions? options = null) + { + LastChildName = workflowName; + LastChildInput = input; + LastChildOptions = options; + return Task.FromResult(default(TResult)!); + } + + public override Microsoft.Extensions.Logging.ILogger CreateReplaySafeLogger(string categoryName) => new NullLogger(); + public override Microsoft.Extensions.Logging.ILogger CreateReplaySafeLogger(Type type) => new NullLogger(); + public override Microsoft.Extensions.Logging.ILogger CreateReplaySafeLogger() => new NullLogger(); + public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) { } + public override Guid NewGuid() => Guid.Empty; + + private sealed class NullLogger : Microsoft.Extensions.Logging.ILogger + { + IDisposable? Microsoft.Extensions.Logging.ILogger.BeginScope(TState state) => null; + bool Microsoft.Extensions.Logging.ILogger.IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => false; + void Microsoft.Extensions.Logging.ILogger.Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, TState state, Exception? exception, Func formatter) { } + } + } + + [Fact] + public async Task CallActivityAsync_NonGeneric_Delegates_To_Generic() + { + var ctx = new ProbeContext(); + var options = new WorkflowTaskOptions(); + await ctx.CallActivityAsync("act", 123, options); + + Assert.Equal("act", ctx.LastActivityName); + Assert.Equal(123, ctx.LastActivityInput); + Assert.Same(options, ctx.LastActivityOptions); + } + + [Fact] + public async Task CreateTimer_With_TimeSpan_Delegates_To_DateTime_Using_CurrentUtc() + { + var ctx = new ProbeContext(); + var delay = TimeSpan.FromMinutes(5); + await ctx.CreateTimer(delay, CancellationToken.None); + + Assert.Equal(new DateTime(2025, 1, 1, 0, 5, 0, DateTimeKind.Utc), ctx.LastTimerFireAt); + } + + [Fact] + public async Task CallChildWorkflowAsync_NonGeneric_Delegates_To_Generic() + { + var ctx = new ProbeContext(); + var options = new ChildWorkflowTaskOptions(); + await ctx.CallChildWorkflowAsync("child", new { X = 1 }, options); + + Assert.Equal("child", ctx.LastChildName); + Assert.NotNull(ctx.LastChildInput); + Assert.Same(options, ctx.LastChildOptions); + } +} diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowRetryPolicyTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowRetryPolicyTests.cs new file mode 100644 index 000000000..440e459cb --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowRetryPolicyTests.cs @@ -0,0 +1,56 @@ +using Dapr.Workflow; + +namespace Dapr.Workflow.Abstractions.Test; + +public class WorkflowRetryPolicyTests +{ + [Fact] + public void Constructor_Validates_Arguments() + { + Assert.Throws(() => new WorkflowRetryPolicy(0, TimeSpan.FromSeconds(1))); + Assert.Throws(() => new WorkflowRetryPolicy(1, TimeSpan.Zero)); + Assert.Throws(() => new WorkflowRetryPolicy(1, TimeSpan.FromSeconds(1), 0.9)); + Assert.Throws(() => new WorkflowRetryPolicy(1, TimeSpan.FromSeconds(5), 1.0, TimeSpan.FromSeconds(1))); + Assert.Throws(() => new WorkflowRetryPolicy(1, TimeSpan.FromSeconds(5), 1.0, null, TimeSpan.FromSeconds(1))); + } + + [Fact] + public void Constructor_Sets_Properties_And_Defaults() + { + var policy = new WorkflowRetryPolicy(5, TimeSpan.FromSeconds(2)); + Assert.Equal(5, policy.MaxNumberOfAttempts); + Assert.Equal(TimeSpan.FromSeconds(2), policy.FirstRetryInterval); + Assert.Equal(1.0, policy.BackoffCoefficient); + // Default max retry interval is 1 hour (stored as null meaning default used in ctor logic) + Assert.Null(policy.MaxRetryInterval); + Assert.Equal(System.Threading.Timeout.InfiniteTimeSpan, policy.RetryTimeout); + } + + [Fact] + public void GetNextDelay_Computes_With_Backoff_And_Caps_At_Max() + { + var policy = new WorkflowRetryPolicy( + maxNumberOfAttempts: 10, + firstRetryInterval: TimeSpan.FromSeconds(1), + backoffCoefficient: 2.0, + maxRetryInterval: TimeSpan.FromSeconds(5), + retryTimeout: TimeSpan.FromMinutes(1)); + + // attempt 1 => 1s + Assert.Equal(TimeSpan.FromSeconds(1), InvokeGetNextDelay(policy, 1)); + // attempt 2 => 2s + Assert.Equal(TimeSpan.FromSeconds(2), InvokeGetNextDelay(policy, 2)); + // attempt 3 => 4s + Assert.Equal(TimeSpan.FromSeconds(4), InvokeGetNextDelay(policy, 3)); + // attempt 4 => 8s, but capped to 5s + Assert.Equal(TimeSpan.FromSeconds(5), InvokeGetNextDelay(policy, 4)); + // non-positive attempt returns zero + Assert.Equal(TimeSpan.Zero, InvokeGetNextDelay(policy, 0)); + } + + private static TimeSpan InvokeGetNextDelay(WorkflowRetryPolicy policy, int attempt) + { + var mi = typeof(WorkflowRetryPolicy).GetMethod("GetNextDelay", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!; + return (TimeSpan)mi.Invoke(policy, new object[] { attempt })!; + } +} diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowRuntimeStatusTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowRuntimeStatusTests.cs new file mode 100644 index 000000000..b2126112e --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowRuntimeStatusTests.cs @@ -0,0 +1,34 @@ +namespace Dapr.Workflow.Abstractions.Test; + +public class WorkflowRuntimeStatusTests +{ + [Fact] + public void Enum_Has_Expected_Values() + { + Assert.Equal(-1, (int)WorkflowRuntimeStatus.Unknown); + Assert.Equal(0, (int)WorkflowRuntimeStatus.Running); + Assert.Equal(1, (int)WorkflowRuntimeStatus.Completed); + Assert.Equal(2, (int)WorkflowRuntimeStatus.ContinuedAsNew); + Assert.Equal(3, (int)WorkflowRuntimeStatus.Failed); + Assert.Equal(4, (int)WorkflowRuntimeStatus.Canceled); + Assert.Equal(5, (int)WorkflowRuntimeStatus.Terminated); + Assert.Equal(6, (int)WorkflowRuntimeStatus.Pending); + Assert.Equal(7, (int)WorkflowRuntimeStatus.Suspended); + Assert.Equal(8, (int)WorkflowRuntimeStatus.Stalled); + } + + [Fact] + public void Enum_ToString_Returns_Names() + { + Assert.Equal("Unknown", WorkflowRuntimeStatus.Unknown.ToString()); + Assert.Equal("Running", WorkflowRuntimeStatus.Running.ToString()); + Assert.Equal("Completed", WorkflowRuntimeStatus.Completed.ToString()); + Assert.Equal("ContinuedAsNew", WorkflowRuntimeStatus.ContinuedAsNew.ToString()); + Assert.Equal("Failed", WorkflowRuntimeStatus.Failed.ToString()); + Assert.Equal("Canceled", WorkflowRuntimeStatus.Canceled.ToString()); + Assert.Equal("Terminated", WorkflowRuntimeStatus.Terminated.ToString()); + Assert.Equal("Pending", WorkflowRuntimeStatus.Pending.ToString()); + Assert.Equal("Suspended", WorkflowRuntimeStatus.Suspended.ToString()); + Assert.Equal("Stalled", WorkflowRuntimeStatus.Stalled.ToString()); + } +} diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowTaskFailureDetailsTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowTaskFailureDetailsTests.cs new file mode 100644 index 000000000..8e8f1c484 --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowTaskFailureDetailsTests.cs @@ -0,0 +1,85 @@ +using Dapr.Workflow; + +namespace Dapr.Workflow.Abstractions.Test; + +public class WorkflowTaskFailureDetailsTests +{ + [Fact] + public void Constructor_Sets_Properties() + { + var details = new WorkflowTaskFailureDetails(typeof(InvalidOperationException).FullName!, "boom", "stack"); + Assert.Equal(typeof(InvalidOperationException).FullName, details.ErrorType); + Assert.Equal("boom", details.ErrorMessage); + Assert.Equal("stack", details.StackTrace); + Assert.Equal($"{typeof(InvalidOperationException).FullName}: boom", details.ToString()); + } + + [Fact] + public void ErrorType_Null_Throws_ArgumentNullException() + { + var details = new WorkflowTaskFailureDetails(null!, "msg"); + var ex = Assert.Throws(() => _ = details.ErrorType); + // Just validate the exception type; implementation details of message/param are not part of API contract. + Assert.IsType(ex); + } + + [Fact] + public void ErrorMessage_Null_Throws_ArgumentNullException() + { + var details = new WorkflowTaskFailureDetails(typeof(Exception).FullName!, null!); + Assert.Throws(() => _ = details.ErrorMessage); + } + + [Fact] + public void IsCausedBy_Returns_True_For_Exact_And_Base_Type() + { + var details = new WorkflowTaskFailureDetails(typeof(InvalidOperationException).FullName!, "boom"); + Assert.True(details.IsCausedBy()); + Assert.True(details.IsCausedBy()); + } + + [Fact] + public void IsCausedBy_Returns_False_For_Unrelated_Type() + { + var details = new WorkflowTaskFailureDetails(typeof(InvalidOperationException).FullName!, "boom"); + Assert.False(details.IsCausedBy()); + } + + [Fact] + public void IsCausedBy_Returns_False_When_Type_Not_Resolvable() + { + var details = new WorkflowTaskFailureDetails("Not.A.Real.TypeName, NotARealAssembly", "boom"); + Assert.False(details.IsCausedBy()); + } + + [Fact] + public void IsCausedBy_Catches_When_ErrorType_Getter_Throws() + { + var details = new WorkflowTaskFailureDetails(null!, "boom"); + // Access through IsCausedBy should catch and return false + Assert.False(details.IsCausedBy()); + } + + [Fact] + public void FromException_Produces_Details() + { + InvalidOperationException original; + try + { + throw new InvalidOperationException("oops"); + } + catch (InvalidOperationException ex) + { + original = ex; + } + var details = typeof(WorkflowTaskFailureDetails) + .GetMethod("FromException", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Static)! + .Invoke(null, new object[] { original }) as WorkflowTaskFailureDetails; + + Assert.NotNull(details); + Assert.Equal(typeof(InvalidOperationException).FullName, details!.ErrorType); + Assert.Equal("oops", details.ErrorMessage); + Assert.False(string.IsNullOrEmpty(details.StackTrace)); + Assert.Contains(nameof(FromException_Produces_Details), details.StackTrace!); + } +} diff --git a/test/Dapr.Workflow.Abstractions.Test/WorkflowTests.cs b/test/Dapr.Workflow.Abstractions.Test/WorkflowTests.cs new file mode 100644 index 000000000..d1d30e3f3 --- /dev/null +++ b/test/Dapr.Workflow.Abstractions.Test/WorkflowTests.cs @@ -0,0 +1,56 @@ +using Dapr.Workflow; + +namespace Dapr.Workflow.Abstractions.Test; + +public class WorkflowTests +{ + private sealed class EchoWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, string input) + => Task.FromResult($"wf:{input}"); + } + + private sealed class NoopWorkflowContext : WorkflowContext + { + public override string Name => "wf"; + public override string InstanceId => "id-1"; + public override DateTime CurrentUtcDateTime => new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + public override bool IsReplaying => false; + public override Task CallActivityAsync(string name, object? input = null, WorkflowTaskOptions? options = null) + => Task.FromResult(default(T)!); + public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken) => Task.CompletedTask; + public override Task WaitForExternalEventAsync(string eventName, CancellationToken cancellationToken = default) + => Task.FromResult(default(T)!); + public override Task WaitForExternalEventAsync(string eventName, TimeSpan timeout) => Task.FromResult(default(T)!); + public override void SendEvent(string instanceId, string eventName, object payload) { } + public override void SetCustomStatus(object? customStatus) { } + public override Task CallChildWorkflowAsync(string workflowName, object? input = null, ChildWorkflowTaskOptions? options = null) + => Task.FromResult(default(TResult)!); + public override Microsoft.Extensions.Logging.ILogger CreateReplaySafeLogger(string categoryName) => new NullLogger(); + public override Microsoft.Extensions.Logging.ILogger CreateReplaySafeLogger(Type type) => new NullLogger(); + public override Microsoft.Extensions.Logging.ILogger CreateReplaySafeLogger() => new NullLogger(); + public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) { } + public override Guid NewGuid() => Guid.Empty; + + private sealed class NullLogger : Microsoft.Extensions.Logging.ILogger + { + IDisposable? Microsoft.Extensions.Logging.ILogger.BeginScope(TState state) => null; + bool Microsoft.Extensions.Logging.ILogger.IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => false; + void Microsoft.Extensions.Logging.ILogger.Log(Microsoft.Extensions.Logging.LogLevel logLevel, Microsoft.Extensions.Logging.EventId eventId, TState state, Exception? exception, Func formatter) { } + } + } + + [Fact] + public async Task Generic_Base_Exposes_IWorkflow_Types_And_Runs() + { + var wf = new EchoWorkflow(); + var i = (IWorkflow)wf; + + Assert.Equal(typeof(string), i.InputType); + Assert.Equal(typeof(string), i.OutputType); + + var ctx = new NoopWorkflowContext(); + var result = await i.RunAsync(ctx, "hi"); + Assert.Equal("wf:hi", (string)result!); + } +} diff --git a/test/Dapr.Workflow.Analyzers.Test/Utilities.cs b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs index 64be64dbd..0b971489a 100644 --- a/test/Dapr.Workflow.Analyzers.Test/Utilities.cs +++ b/test/Dapr.Workflow.Analyzers.Test/Utilities.cs @@ -1,9 +1,6 @@ -using Microsoft.AspNetCore.Builder; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Diagnostics; using System.Collections.Immutable; -using System.Reflection; using Dapr.Analyzers.Common; using Microsoft.Extensions.Hosting; @@ -25,11 +22,10 @@ internal static IReadOnlyList GetReferences() metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(Workflow<,>))); metadataReferences.AddRange(TestUtilities.GetAllReferencesNeededForType(typeof(WorkflowActivity<,>))); metadataReferences.Add(MetadataReference.CreateFromFile(typeof(Task).Assembly.Location)); - metadataReferences.Add(MetadataReference.CreateFromFile(typeof(WebApplication).Assembly.Location)); metadataReferences.Add(MetadataReference.CreateFromFile(typeof(DaprWorkflowClient).Assembly.Location)); metadataReferences.Add(MetadataReference.CreateFromFile(typeof(Microsoft.Extensions.DependencyInjection.ServiceCollection).Assembly.Location)); - metadataReferences.Add(MetadataReference.CreateFromFile(typeof(IApplicationBuilder).Assembly.Location)); metadataReferences.Add(MetadataReference.CreateFromFile(typeof(IHost).Assembly.Location)); + metadataReferences.Add(MetadataReference.CreateFromFile(typeof(Host).Assembly.Location)); return metadataReferences; } } diff --git a/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationCodeFixProviderTests.cs b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationCodeFixProviderTests.cs index 91339aea6..b2d9ce98c 100644 --- a/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationCodeFixProviderTests.cs +++ b/test/Dapr.Workflow.Analyzers.Test/WorkflowRegistrationCodeFixProviderTests.cs @@ -87,14 +87,14 @@ public async Task RegisterWorkflow_WhenAddDaprWorkflowIsNotFound() { const string code = """ using Dapr.Workflow; - using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.Hosting; using System.Threading.Tasks; public static class Program { public static void Main() { - var builder = WebApplication.CreateBuilder(); + var builder = Host.CreateApplicationBuilder(); var app = builder.Build(); } @@ -119,14 +119,14 @@ record OrderResult(string message) { } const string expectedChangedCode = """ using Dapr.Workflow; - using Microsoft.AspNetCore.Builder; + using Microsoft.Extensions.Hosting; using System.Threading.Tasks; public static class Program { public static void Main() { - var builder = WebApplication.CreateBuilder(); + var builder = Host.CreateApplicationBuilder(); builder.Services.AddDaprWorkflow(options => { @@ -162,11 +162,11 @@ public async Task RegisterWorkflow_WhenAddDaprWorkflowIsNotFound_TopLevelStateme { const string code = """ using Dapr.Workflow; - using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; using System.Threading.Tasks; - var builder = WebApplication.CreateBuilder(); + var builder = Host.CreateApplicationBuilder(); var app = builder.Build(); @@ -190,11 +190,11 @@ record OrderResult(string message) { } const string expectedChangedCode = """ using Dapr.Workflow; - using Microsoft.AspNetCore.Builder; using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; using System.Threading.Tasks; - var builder = WebApplication.CreateBuilder(); + var builder = Host.CreateApplicationBuilder(); builder.Services.AddDaprWorkflow(options => { @@ -223,4 +223,217 @@ record OrderResult(string message) { } await VerifyCodeFix.RunTest(code, expectedChangedCode, typeof(object).Assembly.Location, Utilities.GetReferences(), Utilities.GetAnalyzers()); } + + [Fact] + public async Task RegisterWorkflow_WhenAddDaprWorkflowIsNotFound_ConfigureServices_BlockBody() + { + const string code = """ + using Dapr.Workflow; + using Microsoft.Extensions.Hosting; + using System.Threading.Tasks; + + public static class Program + { + public static void Main(string[] args) + { + Host.CreateDefaultBuilder(args) + .ConfigureServices((context, services) => + { + }); + } + + private static async Task ScheduleWorkflow(DaprWorkflowClient client) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, new OrderPayload()); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, OrderPayload order) + { + return Task.FromResult(new OrderResult("Order processed")); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + """; + + const string expectedChangedCode = """ + using Dapr.Workflow; + using Microsoft.Extensions.Hosting; + using System.Threading.Tasks; + + public static class Program + { + public static void Main(string[] args) + { + Host.CreateDefaultBuilder(args) + .ConfigureServices((context, services) => + { + services.AddDaprWorkflow(options => + { + options.RegisterWorkflow(); + }); + }); + } + + private static async Task ScheduleWorkflow(DaprWorkflowClient client) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, new OrderPayload()); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, OrderPayload order) + { + return Task.FromResult(new OrderResult("Order processed")); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + """; + + await VerifyCodeFix.RunTest( + code, + expectedChangedCode, + typeof(object).Assembly.Location, + Utilities.GetReferences(), + Utilities.GetAnalyzers()); + } + + [Fact] + public async Task RegisterWorkflow_WhenAddDaprWorkflowIsNotFound_ConfigureServices_ExpressionBody() + { + const string code = """ + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using System.Threading.Tasks; + + public static class Program + { + public static void Main(string[] args) + { + var _ = new ServiceCollection(); + + Host.CreateDefaultBuilder(args) + .ConfigureServices((context, services) => services.Count.ToString()); + } + + private static async Task ScheduleWorkflow(DaprWorkflowClient client) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, new OrderPayload()); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, OrderPayload order) + { + return Task.FromResult(new OrderResult("Order processed")); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + """; + + const string expectedChangedCode = """ + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using Microsoft.Extensions.Hosting; + using System.Threading.Tasks; + + public static class Program + { + public static void Main(string[] args) + { + var _ = new ServiceCollection(); + + Host.CreateDefaultBuilder(args) + .ConfigureServices((context, services) => + { + services.AddDaprWorkflow(options => + { + options.RegisterWorkflow(); + }); + services.Count.ToString(); + }); + } + + private static async Task ScheduleWorkflow(DaprWorkflowClient client) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, new OrderPayload()); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, OrderPayload order) + { + return Task.FromResult(new OrderResult("Order processed")); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + """; + + await VerifyCodeFix.RunTest( + code, + expectedChangedCode, + typeof(object).Assembly.Location, + Utilities.GetReferences(), + Utilities.GetAnalyzers()); + } + + [Fact] + public async Task RegisterWorkflow_NoChange_WhenAddDaprWorkflowOptionsLambdaIsParenthesized() + { + const string code = """ + using Dapr.Workflow; + using Microsoft.Extensions.DependencyInjection; + using System.Threading.Tasks; + + public static class Program + { + public static void Main() + { + var services = new ServiceCollection(); + + services.AddDaprWorkflow((options) => + { + }); + } + + private static async Task ScheduleWorkflow(DaprWorkflowClient client) + { + await client.ScheduleNewWorkflowAsync(nameof(OrderProcessingWorkflow), null, new OrderPayload()); + } + } + + class OrderProcessingWorkflow : Workflow + { + public override Task RunAsync(WorkflowContext context, OrderPayload order) + { + return Task.FromResult(new OrderResult("Order processed")); + } + } + + record OrderPayload { } + record OrderResult(string message) { } + """; + + // The code fix currently only supports SimpleLambdaExpressionSyntax for AddDaprWorkflow options. + await VerifyCodeFix.RunTest( + code, + code, + typeof(object).Assembly.Location, + Utilities.GetReferences(), + Utilities.GetAnalyzers()); + } } diff --git a/test/Dapr.Workflow.Test/Client/ProtoConvertersTests.cs b/test/Dapr.Workflow.Test/Client/ProtoConvertersTests.cs new file mode 100644 index 000000000..16032c334 --- /dev/null +++ b/test/Dapr.Workflow.Test/Client/ProtoConvertersTests.cs @@ -0,0 +1,166 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Text.Json; +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Client; +using Dapr.Workflow.Serialization; +using Google.Protobuf.WellKnownTypes; + +namespace Dapr.Workflow.Test.Client; + +public class ProtoConvertersTests +{ + [Theory] + [InlineData(OrchestrationStatus.Running, WorkflowRuntimeStatus.Running)] + [InlineData(OrchestrationStatus.Completed, WorkflowRuntimeStatus.Completed)] + [InlineData(OrchestrationStatus.ContinuedAsNew, WorkflowRuntimeStatus.ContinuedAsNew)] + [InlineData(OrchestrationStatus.Failed, WorkflowRuntimeStatus.Failed)] + [InlineData(OrchestrationStatus.Canceled, WorkflowRuntimeStatus.Canceled)] + [InlineData(OrchestrationStatus.Terminated, WorkflowRuntimeStatus.Terminated)] + [InlineData(OrchestrationStatus.Pending, WorkflowRuntimeStatus.Pending)] + [InlineData(OrchestrationStatus.Suspended, WorkflowRuntimeStatus.Suspended)] + [InlineData(OrchestrationStatus.Stalled, WorkflowRuntimeStatus.Stalled)] + public void ToRuntimeStatus_ShouldMapKnownProtoValues_ToExpectedWorkflowRuntimeStatus( + OrchestrationStatus protoStatus, + WorkflowRuntimeStatus expected) + { + var actual = ProtoConverters.ToRuntimeStatus(protoStatus); + + Assert.Equal(expected, actual); + } + + [Fact] + public void ToRuntimeStatus_ShouldReturnUnknown_WhenProtoStatusIsUnrecognized() + { + var actual = ProtoConverters.ToRuntimeStatus((OrchestrationStatus)9999); + + Assert.Equal(WorkflowRuntimeStatus.Unknown, actual); + } + + [Fact] + public void ToWorkflowMetadata_ShouldMapCoreFields_AndPreserveSerializerInstance() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var createdUtc = new DateTime(2025, 01, 02, 03, 04, 05, DateTimeKind.Utc); + var updatedUtc = new DateTime(2025, 02, 03, 04, 05, 06, DateTimeKind.Utc); + + var state = new OrchestrationState + { + InstanceId = "instance-123", + Name = "MyWorkflow", + OrchestrationStatus = OrchestrationStatus.Running, + CreatedTimestamp = Timestamp.FromDateTime(createdUtc), + LastUpdatedTimestamp = Timestamp.FromDateTime(updatedUtc), + Input = "{\"hello\":\"in\"}", + Output = "{\"hello\":\"out\"}", + CustomStatus = "{\"hello\":\"status\"}" + }; + + var metadata = ProtoConverters.ToWorkflowMetadata(state, serializer); + + Assert.Equal("instance-123", metadata.InstanceId); + Assert.Equal("MyWorkflow", metadata.Name); + Assert.Equal(WorkflowRuntimeStatus.Running, metadata.RuntimeStatus); + Assert.Equal(createdUtc, metadata.CreatedAt); + Assert.Equal(updatedUtc, metadata.LastUpdatedAt); + + Assert.Equal("{\"hello\":\"in\"}", metadata.SerializedInput); + Assert.Equal("{\"hello\":\"out\"}", metadata.SerializedOutput); + Assert.Equal("{\"hello\":\"status\"}", metadata.SerializedCustomStatus); + + Assert.Same(serializer, metadata.Serializer); + } + + [Fact] + public void ToWorkflowMetadata_ShouldSetMinValueTimestamps_WhenProtoTimestampsAreNull() + { + var serializer = new JsonWorkflowSerializer(); + + var state = new OrchestrationState + { + InstanceId = "i", + Name = "n", + OrchestrationStatus = OrchestrationStatus.Pending, + CreatedTimestamp = null, + LastUpdatedTimestamp = null + }; + + var metadata = ProtoConverters.ToWorkflowMetadata(state, serializer); + + Assert.Equal(DateTime.MinValue, metadata.CreatedAt); + Assert.Equal(DateTime.MinValue, metadata.LastUpdatedAt); + } + + [Fact] + public void ToWorkflowMetadata_ShouldSetSerializedFieldsToNull_WhenProtoStringsAreNullOrEmpty() + { + var serializer = new JsonWorkflowSerializer(); + + var state = new OrchestrationState + { + InstanceId = "i", + Name = "n", + OrchestrationStatus = OrchestrationStatus.Completed, + Input = "", + Output = null, + CustomStatus = "" + }; + + var metadata = ProtoConverters.ToWorkflowMetadata(state, serializer); + + Assert.Null(metadata.SerializedInput); + Assert.Null(metadata.SerializedOutput); + Assert.Null(metadata.SerializedCustomStatus); + } + + [Fact] + public void ToWorkflowMetadata_ShouldKeepSerializedFields_WhenProtoStringsContainWhitespace() + { + var serializer = new JsonWorkflowSerializer(); + + var state = new OrchestrationState + { + InstanceId = "i", + Name = "n", + OrchestrationStatus = OrchestrationStatus.Completed, + Input = " ", + Output = "\t", + CustomStatus = "\r\n" + }; + + var metadata = ProtoConverters.ToWorkflowMetadata(state, serializer); + + Assert.Equal(" ", metadata.SerializedInput); + Assert.Equal("\t", metadata.SerializedOutput); + Assert.Equal("\r\n", metadata.SerializedCustomStatus); + } + + [Fact] + public void ToWorkflowMetadata_ShouldMapRuntimeStatus_UsingToRuntimeStatus() + { + var serializer = new JsonWorkflowSerializer(); + + var state = new OrchestrationState + { + InstanceId = "i", + Name = "n", + OrchestrationStatus = OrchestrationStatus.ContinuedAsNew + }; + + var metadata = ProtoConverters.ToWorkflowMetadata(state, serializer); + + Assert.Equal(WorkflowRuntimeStatus.ContinuedAsNew, metadata.RuntimeStatus); + } +} diff --git a/test/Dapr.Workflow.Test/Client/StartWorkflowOptionsBuilderTests.cs b/test/Dapr.Workflow.Test/Client/StartWorkflowOptionsBuilderTests.cs new file mode 100644 index 000000000..0e5211ff0 --- /dev/null +++ b/test/Dapr.Workflow.Test/Client/StartWorkflowOptionsBuilderTests.cs @@ -0,0 +1,99 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Workflow.Client; + +namespace Dapr.Workflow.Test.Client; + +public class StartWorkflowOptionsBuilderTests +{ + [Fact] + public void Build_ShouldReturnOptionsWithNullValues_WhenNothingConfigured() + { + var builder = new StartWorkflowOptionsBuilder(); + + var options = builder.Build(); + + Assert.NotNull(options); + Assert.Null(options.InstanceId); + Assert.Null(options.StartAt); + } + + [Fact] + public void WithInstanceId_ShouldSetInstanceId_OnBuiltOptions() + { + var builder = new StartWorkflowOptionsBuilder() + .WithInstanceId("instance-123"); + + var options = builder.Build(); + + Assert.Equal("instance-123", options.InstanceId); + } + + [Fact] + public void StartAt_ShouldSetStartAt_OnBuiltOptions() + { + var startAt = new DateTimeOffset(2026, 02, 03, 04, 05, 06, TimeSpan.Zero); + + var builder = new StartWorkflowOptionsBuilder() + .StartAt(startAt); + + var options = builder.Build(); + + Assert.Equal(startAt, options.StartAt); + } + + [Fact] + public void StartAfter_ShouldSetStartAtCloseToUtcNowPlusDelay_OnBuiltOptions() + { + var delay = TimeSpan.FromSeconds(2); + + var before = DateTimeOffset.UtcNow; + var builder = new StartWorkflowOptionsBuilder().StartAfter(delay); + var after = DateTimeOffset.UtcNow; + + var options = builder.Build(); + + Assert.NotNull(options.StartAt); + + var expectedLowerBound = before.Add(delay); + var expectedUpperBound = after.Add(delay); + + Assert.InRange(options.StartAt!.Value, expectedLowerBound, expectedUpperBound); + } + + [Fact] + public void ImplicitConversion_ShouldBuildOptions() + { + var startAt = new DateTimeOffset(2027, 03, 04, 05, 06, 07, TimeSpan.Zero); + + StartWorkflowOptions options = new StartWorkflowOptionsBuilder() + .WithInstanceId("instance-abc") + .StartAt(startAt); + + Assert.Equal("instance-abc", options.InstanceId); + Assert.Equal(startAt, options.StartAt); + } + + [Fact] + public void FluentCalls_ShouldReturnSameBuilderInstance_ForChaining() + { + var builder = new StartWorkflowOptionsBuilder(); + + var returned1 = builder.WithInstanceId("x"); + var returned2 = builder.StartAt(DateTimeOffset.UtcNow); + + Assert.Same(builder, returned1); + Assert.Same(builder, returned2); + } +} diff --git a/test/Dapr.Workflow.Test/Client/StartWorkflowOptionsTests.cs b/test/Dapr.Workflow.Test/Client/StartWorkflowOptionsTests.cs new file mode 100644 index 000000000..a282d4d66 --- /dev/null +++ b/test/Dapr.Workflow.Test/Client/StartWorkflowOptionsTests.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Workflow.Client; + +namespace Dapr.Workflow.Test.Client; + +public class StartWorkflowOptionsTests +{ + [Fact] + public void NewInstance_ShouldHaveNullInstanceId_AndNullStartAt() + { + var options = new StartWorkflowOptions(); + + Assert.Null(options.InstanceId); + Assert.Null(options.StartAt); + } + + [Fact] + public void Properties_ShouldRoundTrip_WhenAssigned() + { + var expectedInstanceId = "my-instance-id"; + var expectedStartAt = new DateTimeOffset(2025, 01, 02, 03, 04, 05, TimeSpan.Zero); + + var options = new StartWorkflowOptions + { + InstanceId = expectedInstanceId, + StartAt = expectedStartAt + }; + + Assert.Equal(expectedInstanceId, options.InstanceId); + Assert.Equal(expectedStartAt, options.StartAt); + } +} diff --git a/test/Dapr.Workflow.Test/Client/WorkflowGrpcClientTests.cs b/test/Dapr.Workflow.Test/Client/WorkflowGrpcClientTests.cs new file mode 100644 index 000000000..42043cfa0 --- /dev/null +++ b/test/Dapr.Workflow.Test/Client/WorkflowGrpcClientTests.cs @@ -0,0 +1,576 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Client; +using Dapr.Workflow.Serialization; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Dapr.Workflow.Test.Client; + +public class WorkflowGrpcClientTests +{ + [Fact] + public async Task ScheduleNewWorkflowAsync_ShouldUseProvidedInstanceId_WhenOptionsHasInstanceId() + { + var serializer = new StubSerializer { SerializeResult = "{\"x\":1}" }; + + CreateInstanceRequest? capturedRequest = null; + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.StartInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => capturedRequest = r) + .Returns(CreateAsyncUnaryCall(new CreateInstanceResponse { InstanceId = "id-from-sidecar" })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + var instanceId = await client.ScheduleNewWorkflowAsync( + "MyWorkflow", + input: new { A = 1 }, + options: new StartWorkflowOptions { InstanceId = "instance-123" }); + + Assert.Equal("id-from-sidecar", instanceId); + Assert.NotNull(capturedRequest); + Assert.Equal("instance-123", capturedRequest!.InstanceId); + Assert.Equal("MyWorkflow", capturedRequest.Name); + Assert.Equal("{\"x\":1}", capturedRequest.Input); + } + + [Fact] + public async Task ScheduleNewWorkflowAsync_ShouldGenerateNonEmptyInstanceId_WhenOptionsInstanceIdIsNull() + { + var serializer = new StubSerializer { SerializeResult = "" }; + + CreateInstanceRequest? capturedRequest = null; + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.StartInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => capturedRequest = r) + .Returns(CreateAsyncUnaryCall(new CreateInstanceResponse { InstanceId = "returned" })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + await client.ScheduleNewWorkflowAsync("MyWorkflow", input: null, options: new StartWorkflowOptions { InstanceId = null }); + + Assert.NotNull(capturedRequest); + Assert.False(string.IsNullOrEmpty(capturedRequest!.InstanceId)); + } + + [Fact] + public async Task ScheduleNewWorkflowAsync_ShouldSetScheduledStartTimestamp_WhenStartAtSpecified() + { + var serializer = new StubSerializer { SerializeResult = "" }; + var startAt = new DateTimeOffset(2025, 01, 02, 03, 04, 05, TimeSpan.Zero); + + CreateInstanceRequest? capturedRequest = null; + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.StartInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => capturedRequest = r) + .Returns(CreateAsyncUnaryCall(new CreateInstanceResponse { InstanceId = "returned" })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + await client.ScheduleNewWorkflowAsync( + "MyWorkflow", + input: null, + options: new StartWorkflowOptions { InstanceId = "i", StartAt = startAt }); + + Assert.NotNull(capturedRequest); + Assert.NotNull(capturedRequest!.ScheduledStartTimestamp); + Assert.Equal(startAt, capturedRequest.ScheduledStartTimestamp.ToDateTimeOffset()); + } + + [Fact] + public async Task GetWorkflowMetadataAsync_ShouldReturnNull_WhenResponseExistsIsFalse() + { + var serializer = new StubSerializer(); + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateAsyncUnaryCall(new GetInstanceResponse { Exists = false })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + var result = await client.GetWorkflowMetadataAsync("missing", getInputsAndOutputs: true); + + Assert.Null(result); + } + + [Fact] + public async Task GetWorkflowMetadataAsync_ShouldReturnNull_WhenGrpcThrowsNotFound() + { + var serializer = new StubSerializer(); + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateAsyncUnaryCallThrows(new RpcException(new Status(StatusCode.NotFound, "not found")))); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + var result = await client.GetWorkflowMetadataAsync("missing", getInputsAndOutputs: true); + + Assert.Null(result); + } + + [Fact] + public async Task GetWorkflowMetadataAsync_ShouldPassThroughGetInputsAndOutputsFlag() + { + var serializer = new JsonWorkflowSerializer(); + + GetInstanceRequest? captured = null; + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => captured = r) + .Returns(CreateAsyncUnaryCall(new GetInstanceResponse + { + Exists = true, + OrchestrationState = new OrchestrationState { InstanceId = "i", Name = "n", OrchestrationStatus = OrchestrationStatus.Running } + })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + await client.GetWorkflowMetadataAsync("i", getInputsAndOutputs: false); + + Assert.NotNull(captured); + Assert.Equal("i", captured!.InstanceId); + Assert.False(captured.GetInputsAndOutputs); + } + + [Fact] + public async Task WaitForWorkflowStartAsync_ShouldReturnImmediately_WhenStatusIsNotPending() + { + var serializer = new JsonWorkflowSerializer(); + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateAsyncUnaryCall(new GetInstanceResponse + { + Exists = true, + OrchestrationState = new OrchestrationState + { + InstanceId = "i", + Name = "n", + OrchestrationStatus = OrchestrationStatus.Running + } + })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + var result = await client.WaitForWorkflowStartAsync("i", getInputsAndOutputs: true); + + Assert.Equal("i", result.InstanceId); + Assert.Equal(WorkflowRuntimeStatus.Running, result.RuntimeStatus); + } + + [Fact] + public async Task WaitForWorkflowStartAsync_ShouldThrowInvalidOperationException_WhenInstanceDoesNotExist() + { + var serializer = new JsonWorkflowSerializer(); + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateAsyncUnaryCall(new GetInstanceResponse { Exists = false })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + await Assert.ThrowsAsync(() => client.WaitForWorkflowStartAsync("missing", getInputsAndOutputs: true)); + } + + [Fact] + public async Task WaitForWorkflowCompletionAsync_ShouldReturnImmediately_WhenStatusIsTerminal() + { + var serializer = new JsonWorkflowSerializer(); + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateAsyncUnaryCall(new GetInstanceResponse + { + Exists = true, + OrchestrationState = new OrchestrationState + { + InstanceId = "i", + Name = "n", + OrchestrationStatus = OrchestrationStatus.Completed, + Output = "{\"ok\":true}" + } + })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + var result = await client.WaitForWorkflowCompletionAsync("i", getInputsAndOutputs: true); + + Assert.Equal("i", result.InstanceId); + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + Assert.Equal("{\"ok\":true}", result.SerializedOutput); + } + + [Fact] + public async Task RaiseEventAsync_ShouldThrowArgumentException_WhenInstanceIdIsNullOrEmpty() + { + var serializer = new StubSerializer(); + var grpcClientMock = CreateGrpcClientMock(); + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + await Assert.ThrowsAsync(() => client.RaiseEventAsync("", "evt", eventPayload: null)); + } + + [Fact] + public async Task RaiseEventAsync_ShouldThrowArgumentException_WhenEventNameIsNullOrEmpty() + { + var serializer = new StubSerializer(); + var grpcClientMock = CreateGrpcClientMock(); + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + await Assert.ThrowsAsync(() => client.RaiseEventAsync("i", "", eventPayload: null)); + } + + [Fact] + public async Task RaiseEventAsync_ShouldSendSerializedPayload() + { + var serializer = new StubSerializer { SerializeResult = "{\"p\":1}" }; + RaiseEventRequest? captured = null; + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.RaiseEventAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => captured = r) + .Returns(CreateAsyncUnaryCall(new RaiseEventResponse())); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + await client.RaiseEventAsync("i", "evt", new { P = 1 }); + + Assert.NotNull(captured); + Assert.Equal("i", captured!.InstanceId); + Assert.Equal("evt", captured.Name); + Assert.Equal("{\"p\":1}", captured.Input); + } + + [Fact] + public async Task TerminateWorkflowAsync_ShouldSendRecursiveTrue_AndSerializedOutput() + { + var serializer = new StubSerializer { SerializeResult = "{\"done\":true}" }; + TerminateRequest? captured = null; + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.TerminateInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => captured = r) + .Returns(CreateAsyncUnaryCall(new TerminateResponse())); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + await client.TerminateWorkflowAsync("i", output: new { Done = true }); + + Assert.NotNull(captured); + Assert.Equal("i", captured!.InstanceId); + Assert.True(captured.Recursive); + Assert.Equal("{\"done\":true}", captured.Output); + } + + [Fact] + public async Task SuspendWorkflowAsync_ShouldSendEmptyReason_WhenReasonIsNull() + { + var serializer = new StubSerializer(); + SuspendRequest? captured = null; + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.SuspendInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => captured = r) + .Returns(CreateAsyncUnaryCall(new SuspendResponse())); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + await client.SuspendWorkflowAsync("i", reason: null); + + Assert.NotNull(captured); + Assert.Equal("i", captured!.InstanceId); + Assert.Equal(string.Empty, captured.Reason); + } + + [Fact] + public async Task ResumeWorkflowAsync_ShouldSendEmptyReason_WhenReasonIsNull() + { + var serializer = new StubSerializer(); + ResumeRequest? captured = null; + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.ResumeInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => captured = r) + .Returns(CreateAsyncUnaryCall(new ResumeResponse())); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + await client.ResumeWorkflowAsync("i", reason: null); + + Assert.NotNull(captured); + Assert.Equal("i", captured!.InstanceId); + Assert.Equal(string.Empty, captured.Reason); + } + + [Theory] + [InlineData(0, false)] + [InlineData(1, true)] + [InlineData(2, true)] + public async Task PurgeInstanceAsync_ShouldReturnTrueOnlyWhenDeletedInstanceCountGreaterThanZero(int deletedCount, bool expected) + { + var serializer = new StubSerializer(); + PurgeInstancesRequest? captured = null; + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.PurgeInstancesAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => captured = r) + .Returns(CreateAsyncUnaryCall(new PurgeInstancesResponse { DeletedInstanceCount = deletedCount })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + var result = await client.PurgeInstanceAsync("i"); + + Assert.NotNull(captured); + Assert.Equal("i", captured!.InstanceId); + Assert.True(captured.Recursive); + Assert.Equal(expected, result); + } + + [Fact] + public async Task DisposeAsync_ShouldCompleteSynchronously() + { + var serializer = new StubSerializer(); + var grpcClientMock = CreateGrpcClientMock(); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + await client.DisposeAsync(); + } + + [Fact] + public async Task WaitForWorkflowStartAsync_ShouldPollUntilStatusIsNotPending() + { + var serializer = new JsonWorkflowSerializer(); + + var grpcClientMock = CreateGrpcClientMock(); + + var requests = new List(); + var callCount = 0; + + grpcClientMock + .Setup(x => x.GetInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Returns((GetInstanceRequest request, Metadata? _, DateTime? __, CancellationToken ___) => + { + requests.Add(request); + callCount++; + + var status = callCount == 1 ? OrchestrationStatus.Pending : OrchestrationStatus.Running; + + return CreateAsyncUnaryCall(new GetInstanceResponse + { + Exists = true, + OrchestrationState = new OrchestrationState + { + InstanceId = "i", + Name = "n", + OrchestrationStatus = status + } + }); + }); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var result = await client.WaitForWorkflowStartAsync("i", getInputsAndOutputs: false, cancellationToken: cts.Token); + + Assert.Equal("i", result.InstanceId); + Assert.Equal(WorkflowRuntimeStatus.Running, result.RuntimeStatus); + + Assert.True(requests.Count >= 2); + Assert.All(requests, r => Assert.Equal("i", r.InstanceId)); + Assert.All(requests, r => Assert.False(r.GetInputsAndOutputs)); + } + + [Fact] + public async Task WaitForWorkflowCompletionAsync_ShouldPollUntilTerminalStatus() + { + var serializer = new JsonWorkflowSerializer(); + + var grpcClientMock = CreateGrpcClientMock(); + + var requests = new List(); + var callCount = 0; + + grpcClientMock + .Setup(x => x.GetInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Returns((GetInstanceRequest request, Metadata? _, DateTime? __, CancellationToken ___) => + { + requests.Add(request); + callCount++; + + var status = callCount == 1 ? OrchestrationStatus.Running : OrchestrationStatus.Completed; + + return CreateAsyncUnaryCall(new GetInstanceResponse + { + Exists = true, + OrchestrationState = new OrchestrationState + { + InstanceId = "i", + Name = "n", + OrchestrationStatus = status, + Output = status == OrchestrationStatus.Completed ? "\"done\"" : string.Empty + } + }); + }); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + using var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5)); + var result = await client.WaitForWorkflowCompletionAsync("i", getInputsAndOutputs: true, cancellationToken: cts.Token); + + Assert.Equal("i", result.InstanceId); + Assert.Equal(WorkflowRuntimeStatus.Completed, result.RuntimeStatus); + Assert.Equal("\"done\"", result.SerializedOutput); + + Assert.True(requests.Count >= 2); + Assert.All(requests, r => Assert.Equal("i", r.InstanceId)); + Assert.All(requests, r => Assert.True(r.GetInputsAndOutputs)); + } + + [Theory] + [InlineData(OrchestrationStatus.Failed, WorkflowRuntimeStatus.Failed)] + [InlineData(OrchestrationStatus.Terminated, WorkflowRuntimeStatus.Terminated)] + public async Task WaitForWorkflowCompletionAsync_ShouldReturnImmediately_WhenStatusIsTerminalFailedOrTerminated( + OrchestrationStatus protoStatus, + WorkflowRuntimeStatus expectedStatus) + { + var serializer = new JsonWorkflowSerializer(); + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateAsyncUnaryCall(new GetInstanceResponse + { + Exists = true, + OrchestrationState = new OrchestrationState + { + InstanceId = "i", + Name = "n", + OrchestrationStatus = protoStatus + } + })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + var result = await client.WaitForWorkflowCompletionAsync("i", getInputsAndOutputs: true); + + Assert.Equal("i", result.InstanceId); + Assert.Equal(expectedStatus, result.RuntimeStatus); + } + + [Fact] + public async Task WaitForWorkflowCompletionAsync_ShouldThrowInvalidOperationException_WhenInstanceDoesNotExist() + { + var serializer = new JsonWorkflowSerializer(); + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateAsyncUnaryCall(new GetInstanceResponse { Exists = false })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + var ex = await Assert.ThrowsAsync( + () => client.WaitForWorkflowCompletionAsync("missing", getInputsAndOutputs: true)); + + Assert.Contains("missing", ex.Message); + } + + [Fact] + public async Task WaitForWorkflowCompletionAsync_ShouldRespectCancellationToken_WhileWaiting() + { + var serializer = new JsonWorkflowSerializer(); + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetInstanceAsync(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateAsyncUnaryCall(new GetInstanceResponse + { + Exists = true, + OrchestrationState = new OrchestrationState + { + InstanceId = "i", + Name = "n", + OrchestrationStatus = OrchestrationStatus.Running + } + })); + + var client = new WorkflowGrpcClient(grpcClientMock.Object, NullLogger.Instance, serializer); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(50)); + + await Assert.ThrowsAnyAsync( + () => client.WaitForWorkflowCompletionAsync("i", getInputsAndOutputs: true, cancellationToken: cts.Token)); + } + + private static Mock CreateGrpcClientMock() + { + var callInvoker = new Mock(MockBehavior.Loose); + return new Mock(MockBehavior.Loose, callInvoker.Object); + } + + private static AsyncUnaryCall CreateAsyncUnaryCall(T response) + where T : class + { + return new AsyncUnaryCall( + Task.FromResult(response), + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => new Metadata(), + () => { }); + } + + private static AsyncUnaryCall CreateAsyncUnaryCallThrows(Exception ex) + where T : class + { + return new AsyncUnaryCall( + Task.FromException(ex), + Task.FromResult(new Metadata()), + () => new Status(StatusCode.Unknown, "error"), + () => new Metadata(), + () => { }); + } + + private sealed class StubSerializer : IWorkflowSerializer + { + public string SerializeResult { get; set; } = string.Empty; + + public string Serialize(object? value, Type? inputType = null) => value is null ? string.Empty : SerializeResult; + + public T? Deserialize(string? data) => throw new NotSupportedException(); + + public object? Deserialize(string? data, Type returnType) => throw new NotSupportedException(); + } +} diff --git a/test/Dapr.Workflow.Test/Client/WorkflowMetadataTests.cs b/test/Dapr.Workflow.Test/Client/WorkflowMetadataTests.cs new file mode 100644 index 000000000..f6cacd523 --- /dev/null +++ b/test/Dapr.Workflow.Test/Client/WorkflowMetadataTests.cs @@ -0,0 +1,157 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Workflow.Client; +using Dapr.Workflow.Serialization; + +namespace Dapr.Workflow.Test.Client; + +public class WorkflowMetadataTests +{ + [Theory] + [InlineData(null, false)] + [InlineData("", false)] + [InlineData(" ", true)] + [InlineData("instance-1", true)] + public void Exists_ShouldBeTrueOnlyWhenInstanceIdIsNotNullOrEmpty(string? instanceId, bool expected) + { + var metadata = new WorkflowMetadata( + instanceId ?? string.Empty, + "workflow", + WorkflowRuntimeStatus.Running, + DateTime.MinValue, + DateTime.MinValue, + new CapturingSerializer()); + + Assert.Equal(expected, metadata.Exists); + } + + [Fact] + public void ReadInputAs_ShouldReturnDefault_WhenSerializedInputIsNull() + { + var serializer = new CapturingSerializer(); + var metadata = new WorkflowMetadata( + "i", + "w", + WorkflowRuntimeStatus.Running, + DateTime.MinValue, + DateTime.MinValue, + serializer) + { + SerializedInput = null + }; + + var value = metadata.ReadInputAs(); + + Assert.Equal(default, value); + Assert.Equal(0, serializer.GenericDeserializeCallCount); + } + + [Fact] + public void ReadInputAs_ShouldReturnDefault_WhenSerializedInputIsEmpty() + { + var serializer = new CapturingSerializer(); + var metadata = new WorkflowMetadata( + "i", + "w", + WorkflowRuntimeStatus.Running, + DateTime.MinValue, + DateTime.MinValue, + serializer) + { + SerializedInput = "" + }; + + var value = metadata.ReadInputAs(); + + Assert.Null(value); + Assert.Equal(0, serializer.GenericDeserializeCallCount); + } + + [Fact] + public void ReadInputAs_ShouldUseSerializer_WhenSerializedInputIsPresent() + { + var expected = new TestPayload { Value = "from-input" }; + var serializer = new CapturingSerializer { NextGenericResult = expected }; + + var metadata = new WorkflowMetadata( + "i", + "w", + WorkflowRuntimeStatus.Running, + DateTime.MinValue, + DateTime.MinValue, + serializer) + { + SerializedInput = "{\"value\":\"from-input\"}" + }; + + var value = metadata.ReadInputAs(); + + Assert.Same(expected, value); + Assert.Equal(1, serializer.GenericDeserializeCallCount); + Assert.Equal("{\"value\":\"from-input\"}", serializer.LastGenericDeserializeData); + } + + [Fact] + public void ReadOutputAs_ShouldReturnDefault_WhenSerializedOutputIsNullOrEmpty() + { + var serializer = new CapturingSerializer(); + var metadataNull = new WorkflowMetadata("i", "w", WorkflowRuntimeStatus.Running, DateTime.MinValue, DateTime.MinValue, serializer) + { + SerializedOutput = null + }; + var metadataEmpty = metadataNull with { SerializedOutput = "" }; + + Assert.Equal(default, metadataNull.ReadOutputAs()); + Assert.Equal(default, metadataEmpty.ReadOutputAs()); + Assert.Equal(0, serializer.GenericDeserializeCallCount); + } + + [Fact] + public void ReadCustomStatusAs_ShouldReturnDefault_WhenSerializedCustomStatusIsNullOrEmpty() + { + var serializer = new CapturingSerializer(); + var metadataNull = new WorkflowMetadata("i", "w", WorkflowRuntimeStatus.Running, DateTime.MinValue, DateTime.MinValue, serializer) + { + SerializedCustomStatus = null + }; + var metadataEmpty = metadataNull with { SerializedCustomStatus = "" }; + + Assert.Null(metadataNull.ReadCustomStatusAs()); + Assert.Null(metadataEmpty.ReadCustomStatusAs()); + Assert.Equal(0, serializer.GenericDeserializeCallCount); + } + + private sealed class TestPayload + { + public string? Value { get; set; } + } + + private sealed class CapturingSerializer : IWorkflowSerializer + { + public int GenericDeserializeCallCount { get; private set; } + public string? LastGenericDeserializeData { get; private set; } + public object? NextGenericResult { get; set; } + + public string Serialize(object? value, Type? inputType = null) => throw new NotSupportedException(); + + public T? Deserialize(string? data) + { + GenericDeserializeCallCount++; + LastGenericDeserializeData = data; + return (T?)NextGenericResult; + } + + public object? Deserialize(string? data, Type returnType) => throw new NotSupportedException(); + } +} diff --git a/test/Dapr.Workflow.Test/DaprWorkflowClientTests.cs b/test/Dapr.Workflow.Test/DaprWorkflowClientTests.cs index 5efef048f..b424923bf 100644 --- a/test/Dapr.Workflow.Test/DaprWorkflowClientTests.cs +++ b/test/Dapr.Workflow.Test/DaprWorkflowClientTests.cs @@ -11,107 +11,429 @@ // limitations under the License. // ------------------------------------------------------------------------ -using Dapr.DurableTask; -using Moq; +using Dapr.Workflow.Client; namespace Dapr.Workflow.Test; public class DaprWorkflowClientTests { [Fact] - public async Task ScheduleNewWorkflowAsync_DateTimeKindUnspecified_AssumesLocalTime() + public void Constructor_ShouldThrowArgumentNullException_WhenInnerClientIsNull() { - var innerClient = new Mock(); - - var name = "test-workflow"; - var instanceId = "test-instance-id"; - var input = "test-input"; - var startTime = new DateTime(2025, 07, 10); - - Assert.Equal(DateTimeKind.Unspecified, startTime.Kind); - - innerClient.Setup(c => c.ScheduleNewOrchestrationInstanceAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Callback((TaskName n, object? i, StartOrchestrationOptions? o, CancellationToken ct) => - { - Assert.Equal(name, n); - Assert.Equal(input, i); - Assert.NotNull(o); - Assert.NotNull(o.StartAt); - // options configured with local time - Assert.Equal(new DateTimeOffset(startTime, TimeZoneInfo.Local.GetUtcOffset(DateTime.Now)), o.StartAt.Value); - }) - .ReturnsAsync("instance-id"); - - var client = new DaprWorkflowClient(innerClient.Object); - - await client.ScheduleNewWorkflowAsync(name, instanceId, input, startTime); + Assert.Throws(() => new DaprWorkflowClient(null!)); } [Fact] - public async Task ScheduleNewWorkflowAsync_DateTimeKindUtc_PreservedAsUtc() + public async Task ScheduleNewWorkflowAsync_ShouldThrowArgumentException_WhenNameIsNullOrEmpty() { - var innerClient = new Mock(); - - var name = "test-workflow"; - var instanceId = "test-instance-id"; - var input = "test-input"; - var startTime = new DateTime(2025, 07, 10, 1, 30, 30, DateTimeKind.Utc); - - Assert.Equal(DateTimeKind.Utc, startTime.Kind); - - innerClient.Setup(c => c.ScheduleNewOrchestrationInstanceAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Callback((TaskName n, object? i, StartOrchestrationOptions? o, CancellationToken ct) => - { - Assert.Equal(name, n); - Assert.Equal(input, i); - Assert.NotNull(o); - Assert.NotNull(o.StartAt); - // options configured with UTC time - Assert.Equal(new DateTimeOffset(startTime, TimeSpan.Zero), o.StartAt.Value); - }) - .ReturnsAsync("instance-id"); - - var client = new DaprWorkflowClient(innerClient.Object); - - await client.ScheduleNewWorkflowAsync(name, instanceId, input, startTime); + var inner = new CapturingWorkflowClient(); + var client = new DaprWorkflowClient(inner); + + await Assert.ThrowsAsync(() => client.ScheduleNewWorkflowAsync("")); + } + + [Fact] + public async Task ScheduleNewWorkflowAsync_ShouldForwardToInnerClient_WithStartOptionsAndCancellationToken() + { + var inner = new CapturingWorkflowClient { ScheduleNewWorkflowResult = "returned-id" }; + var client = new DaprWorkflowClient(inner); + + using var cts = new CancellationTokenSource(); + var startAt = new DateTimeOffset(2025, 01, 02, 03, 04, 05, TimeSpan.Zero); + + var instanceId = await client.ScheduleNewWorkflowAsync( + name: "MyWorkflow", + instanceId: "instance-123", + input: new { A = 1 }, + startTime: startAt, + cancellation: cts.Token); + + Assert.Equal("returned-id", instanceId); + + Assert.Equal("MyWorkflow", inner.LastScheduleName); + Assert.NotNull(inner.LastScheduleOptions); + Assert.Equal("instance-123", inner.LastScheduleOptions!.InstanceId); + Assert.Equal(startAt, inner.LastScheduleOptions.StartAt); + Assert.Equal(cts.Token, inner.LastScheduleCancellationToken); + Assert.NotNull(inner.LastScheduleInput); } [Fact] - public async Task ScheduleNewWorkflowAsync_DateTimeOffset_SetsStartAt() + public async Task ScheduleNewWorkflowAsync_DateTimeOverload_ShouldConvertToDateTimeOffset_WhenStartTimeProvided() { - var innerClient = new Mock(); - - var name = "test-workflow"; - var instanceId = "test-instance-id"; - var input = "test-input"; - var startTime = new DateTimeOffset(2025, 07, 10, 1, 30, 30, TimeSpan.FromHours(3)); - - innerClient.Setup(c => c.ScheduleNewOrchestrationInstanceAsync( - It.IsAny(), - It.IsAny(), - It.IsAny(), - It.IsAny())) - .Callback((TaskName n, object? i, StartOrchestrationOptions? o, CancellationToken ct) => - { - Assert.Equal(name, n); - Assert.Equal(input, i); - Assert.NotNull(o); - Assert.NotNull(o.StartAt); - // options configured with specified offset - Assert.Equal(startTime, o.StartAt.Value); - }) - .ReturnsAsync("instance-id"); - - var client = new DaprWorkflowClient(innerClient.Object); - - await client.ScheduleNewWorkflowAsync(name, instanceId, input, startTime); + var inner = new CapturingWorkflowClient { ScheduleNewWorkflowResult = "id" }; + var client = new DaprWorkflowClient(inner); + + var start = new DateTime(2025, 07, 10, 1, 2, 3, DateTimeKind.Utc); + + await client.ScheduleNewWorkflowAsync("wf", "i", input: null, startTime: start); + + Assert.NotNull(inner.LastScheduleOptions); + Assert.NotNull(inner.LastScheduleOptions!.StartAt); + Assert.Equal(new DateTimeOffset(start), inner.LastScheduleOptions.StartAt); + } + + [Fact] + public async Task GetWorkflowStateAsync_ShouldThrowArgumentException_WhenInstanceIdIsNullOrEmpty() + { + var inner = new CapturingWorkflowClient(); + var client = new DaprWorkflowClient(inner); + + await Assert.ThrowsAsync(() => client.GetWorkflowStateAsync("")); + } + + [Fact] + public async Task GetWorkflowStateAsync_ShouldReturnNull_WhenInnerReturnsNullMetadata() + { + var inner = new CapturingWorkflowClient { GetWorkflowMetadataResult = null }; + var client = new DaprWorkflowClient(inner); + + var state = await client.GetWorkflowStateAsync("missing"); + + Assert.Null(state); + Assert.Equal("missing", inner.LastGetMetadataInstanceId); + Assert.True(inner.LastGetMetadataGetInputsAndOutputs); + } + + [Fact] + public async Task GetWorkflowStateAsync_ShouldReturnWorkflowState_WhenInnerReturnsMetadata() + { + var metadata = new WorkflowMetadata( + InstanceId: "i", + Name: "wf", + RuntimeStatus: WorkflowRuntimeStatus.Running, + CreatedAt: DateTime.MinValue, + LastUpdatedAt: DateTime.MinValue, + Serializer: new Serialization.JsonWorkflowSerializer()); + + var inner = new CapturingWorkflowClient { GetWorkflowMetadataResult = metadata }; + var client = new DaprWorkflowClient(inner); + + var state = await client.GetWorkflowStateAsync("i"); + + Assert.NotNull(state); + Assert.True(state!.Exists); + Assert.True(state.IsWorkflowRunning); + Assert.Equal(WorkflowRuntimeStatus.Running, state.RuntimeStatus); + } + + [Fact] + public async Task WaitForWorkflowStartAsync_ShouldThrowArgumentException_WhenInstanceIdIsNullOrEmpty() + { + var inner = new CapturingWorkflowClient(); + var client = new DaprWorkflowClient(inner); + + await Assert.ThrowsAsync(() => client.WaitForWorkflowStartAsync("")); + } + + [Fact] + public async Task WaitForWorkflowStartAsync_ShouldWrapMetadataIntoWorkflowState() + { + var metadata = new WorkflowMetadata( + InstanceId: "i", + Name: "wf", + RuntimeStatus: WorkflowRuntimeStatus.Running, + CreatedAt: DateTime.MinValue, + LastUpdatedAt: DateTime.MinValue, + Serializer: new Serialization.JsonWorkflowSerializer()); + + var inner = new CapturingWorkflowClient { WaitForStartResult = metadata }; + var client = new DaprWorkflowClient(inner); + + var state = await client.WaitForWorkflowStartAsync("i", getInputsAndOutputs: false); + + Assert.True(state.Exists); + Assert.Equal(WorkflowRuntimeStatus.Running, state.RuntimeStatus); + Assert.Equal("i", inner.LastWaitForStartInstanceId); + Assert.False(inner.LastWaitForStartGetInputsAndOutputs); + } + + [Fact] + public async Task WaitForWorkflowCompletionAsync_ShouldThrowArgumentException_WhenInstanceIdIsNullOrEmpty() + { + var inner = new CapturingWorkflowClient(); + var client = new DaprWorkflowClient(inner); + + await Assert.ThrowsAsync(() => client.WaitForWorkflowCompletionAsync("")); + } + + [Fact] + public async Task RaiseEventAsync_ShouldValidateParameters_AndForwardToInner() + { + var inner = new CapturingWorkflowClient(); + var client = new DaprWorkflowClient(inner); + + await Assert.ThrowsAsync(() => client.RaiseEventAsync("", "evt")); + await Assert.ThrowsAsync(() => client.RaiseEventAsync("i", "")); + + await client.RaiseEventAsync("i", "evt", new { P = 1 }); + + Assert.Equal("i", inner.LastRaiseEventInstanceId); + Assert.Equal("evt", inner.LastRaiseEventName); + Assert.NotNull(inner.LastRaiseEventPayload); + } + + [Fact] + public async Task TerminateSuspendResumePurge_ShouldValidateInstanceId_AndForwardToInner() + { + var inner = new CapturingWorkflowClient { PurgeResult = true }; + var client = new DaprWorkflowClient(inner); + + await Assert.ThrowsAsync(() => client.TerminateWorkflowAsync("")); + await Assert.ThrowsAsync(() => client.SuspendWorkflowAsync("")); + await Assert.ThrowsAsync(() => client.ResumeWorkflowAsync("")); + await Assert.ThrowsAsync(() => client.PurgeInstanceAsync("")); + + await client.TerminateWorkflowAsync("i", output: "o"); + await client.SuspendWorkflowAsync("i", reason: "r1"); + await client.ResumeWorkflowAsync("i", reason: "r2"); + var purged = await client.PurgeInstanceAsync("i"); + + Assert.Equal("i", inner.LastTerminateInstanceId); + Assert.Equal("i", inner.LastSuspendInstanceId); + Assert.Equal("i", inner.LastResumeInstanceId); + Assert.Equal("i", inner.LastPurgeInstanceId); + Assert.True(purged); + } + + [Fact] + public async Task DisposeAsync_ShouldForwardToInner() + { + var inner = new CapturingWorkflowClient(); + var client = new DaprWorkflowClient(inner); + + await client.DisposeAsync(); + + Assert.True(inner.DisposeCalled); + } + + private sealed class CapturingWorkflowClient : WorkflowClient + { + public string? LastScheduleName { get; private set; } + public object? LastScheduleInput { get; private set; } + public StartWorkflowOptions? LastScheduleOptions { get; private set; } + public CancellationToken LastScheduleCancellationToken { get; private set; } + public string ScheduleNewWorkflowResult { get; set; } = "id"; + + public string? LastGetMetadataInstanceId { get; private set; } + public bool LastGetMetadataGetInputsAndOutputs { get; private set; } + public WorkflowMetadata? GetWorkflowMetadataResult { get; set; } + + public string? LastWaitForStartInstanceId { get; private set; } + public bool LastWaitForStartGetInputsAndOutputs { get; private set; } + public WorkflowMetadata WaitForStartResult { get; set; } = + new("i", "wf", WorkflowRuntimeStatus.Running, DateTime.MinValue, DateTime.MinValue, new Serialization.JsonWorkflowSerializer()); + + public WorkflowMetadata WaitForCompletionResult { get; set; } = + new("i", "wf", WorkflowRuntimeStatus.Completed, DateTime.MinValue, DateTime.MinValue, new Serialization.JsonWorkflowSerializer()); + + public string? LastRaiseEventInstanceId { get; private set; } + public string? LastRaiseEventName { get; private set; } + public object? LastRaiseEventPayload { get; private set; } + + public string? LastTerminateInstanceId { get; private set; } + public object? LastTerminateOutput { get; private set; } + + public string? LastSuspendInstanceId { get; private set; } + public string? LastSuspendReason { get; private set; } + + public string? LastResumeInstanceId { get; private set; } + public string? LastResumeReason { get; private set; } + + public string? LastPurgeInstanceId { get; private set; } + public bool PurgeResult { get; set; } + + public bool DisposeCalled { get; private set; } + + public override Task ScheduleNewWorkflowAsync( + string workflowName, + object? input = null, + StartWorkflowOptions? options = null, + CancellationToken cancellation = default) + { + LastScheduleName = workflowName; + LastScheduleInput = input; + LastScheduleOptions = options; + LastScheduleCancellationToken = cancellation; + return Task.FromResult(ScheduleNewWorkflowResult); + } + + public override Task GetWorkflowMetadataAsync( + string instanceId, + bool getInputsAndOutputs = true, + CancellationToken cancellationToken = default) + { + LastGetMetadataInstanceId = instanceId; + LastGetMetadataGetInputsAndOutputs = getInputsAndOutputs; + return Task.FromResult(GetWorkflowMetadataResult); + } + + public override Task WaitForWorkflowStartAsync( + string instanceId, + bool getInputsAndOutputs = true, + CancellationToken cancellationToken = default) + { + LastWaitForStartInstanceId = instanceId; + LastWaitForStartGetInputsAndOutputs = getInputsAndOutputs; + return Task.FromResult(WaitForStartResult); + } + + public override Task WaitForWorkflowCompletionAsync( + string instanceId, + bool getInputsAndOutputs = true, + CancellationToken cancellationToken = default) + => Task.FromResult(WaitForCompletionResult); + + public override Task RaiseEventAsync( + string instanceId, + string eventName, + object? eventPayload = null, + CancellationToken cancellationToken = default) + { + LastRaiseEventInstanceId = instanceId; + LastRaiseEventName = eventName; + LastRaiseEventPayload = eventPayload; + return Task.CompletedTask; + } + + public override Task TerminateWorkflowAsync( + string instanceId, + object? output = null, + CancellationToken cancellationToken = default) + { + LastTerminateInstanceId = instanceId; + LastTerminateOutput = output; + return Task.CompletedTask; + } + + public override Task SuspendWorkflowAsync( + string instanceId, + string? reason = null, + CancellationToken cancellationToken = default) + { + LastSuspendInstanceId = instanceId; + LastSuspendReason = reason; + return Task.CompletedTask; + } + + public override Task ResumeWorkflowAsync( + string instanceId, + string? reason = null, + CancellationToken cancellationToken = default) + { + LastResumeInstanceId = instanceId; + LastResumeReason = reason; + return Task.CompletedTask; + } + + public override Task PurgeInstanceAsync(string instanceId, CancellationToken cancellationToken = default) + { + LastPurgeInstanceId = instanceId; + return Task.FromResult(PurgeResult); + } + + public override ValueTask DisposeAsync() + { + DisposeCalled = true; + return ValueTask.CompletedTask; + } } + + +// [Fact] +// public async Task ScheduleNewWorkflowAsync_DateTimeKindUnspecified_AssumesLocalTime() +// { +// var innerClient = new Mock(); +// +// var name = "test-workflow"; +// var instanceId = "test-instance-id"; +// var input = "test-input"; +// var startTime = new DateTime(2025, 07, 10); +// +// Assert.Equal(DateTimeKind.Unspecified, startTime.Kind); +// +// innerClient.Setup(c => c.ScheduleNewOrchestrationInstanceAsync( +// It.IsAny(), +// It.IsAny(), +// It.IsAny(), +// It.IsAny())) +// .Callback((TaskName n, object? i, StartOrchestrationOptions? o, CancellationToken ct) => +// { +// Assert.Equal(name, n); +// Assert.Equal(input, i); +// Assert.NotNull(o); +// Assert.NotNull(o.StartAt); +// // options configured with local time +// Assert.Equal(new DateTimeOffset(startTime, TimeZoneInfo.Local.GetUtcOffset(DateTime.Now)), o.StartAt.Value); +// }) +// .ReturnsAsync("instance-id"); +// +// var client = new DaprWorkflowClient(innerClient.Object); +// +// await client.ScheduleNewWorkflowAsync(name, instanceId, input, startTime); +// } +// +// [Fact] +// public async Task ScheduleNewWorkflowAsync_DateTimeKindUtc_PreservedAsUtc() +// { +// var innerClient = new Mock(); +// +// var name = "test-workflow"; +// var instanceId = "test-instance-id"; +// var input = "test-input"; +// var startTime = new DateTime(2025, 07, 10, 1, 30, 30, DateTimeKind.Utc); +// +// Assert.Equal(DateTimeKind.Utc, startTime.Kind); +// +// innerClient.Setup(c => c.ScheduleNewOrchestrationInstanceAsync( +// It.IsAny(), +// It.IsAny(), +// It.IsAny(), +// It.IsAny())) +// .Callback((TaskName n, object? i, StartOrchestrationOptions? o, CancellationToken ct) => +// { +// Assert.Equal(name, n); +// Assert.Equal(input, i); +// Assert.NotNull(o); +// Assert.NotNull(o.StartAt); +// // options configured with UTC time +// Assert.Equal(new DateTimeOffset(startTime, TimeSpan.Zero), o.StartAt.Value); +// }) +// .ReturnsAsync("instance-id"); +// +// var client = new DaprWorkflowClient(innerClient.Object); +// +// await client.ScheduleNewWorkflowAsync(name, instanceId, input, startTime); +// } +// +// [Fact] +// public async Task ScheduleNewWorkflowAsync_DateTimeOffset_SetsStartAt() +// { +// var innerClient = new Mock(); +// +// var name = "test-workflow"; +// var instanceId = "test-instance-id"; +// var input = "test-input"; +// var startTime = new DateTimeOffset(2025, 07, 10, 1, 30, 30, TimeSpan.FromHours(3)); +// +// innerClient.Setup(c => c.ScheduleNewOrchestrationInstanceAsync( +// It.IsAny(), +// It.IsAny(), +// It.IsAny(), +// It.IsAny())) +// .Callback((TaskName n, object? i, StartOrchestrationOptions? o, CancellationToken ct) => +// { +// Assert.Equal(name, n); +// Assert.Equal(input, i); +// Assert.NotNull(o); +// Assert.NotNull(o.StartAt); +// // options configured with specified offset +// Assert.Equal(startTime, o.StartAt.Value); +// }) +// .ReturnsAsync("instance-id"); +// +// var client = new DaprWorkflowClient(innerClient.Object); +// +// await client.ScheduleNewWorkflowAsync(name, instanceId, input, startTime); +// } } diff --git a/test/Dapr.Workflow.Test/DaprWorlflowClientTests.cs b/test/Dapr.Workflow.Test/DaprWorlflowClientTests.cs new file mode 100644 index 000000000..1ac203087 --- /dev/null +++ b/test/Dapr.Workflow.Test/DaprWorlflowClientTests.cs @@ -0,0 +1,19 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +namespace Dapr.Workflow.Test; + +public class DaprWorlflowClientTests +{ + +} diff --git a/test/Dapr.Workflow.Test/GrpcDurableTaskClientWrapper.cs b/test/Dapr.Workflow.Test/GrpcDurableTaskClientWrapper.cs index 16c92f929..55b9e9dfa 100644 --- a/test/Dapr.Workflow.Test/GrpcDurableTaskClientWrapper.cs +++ b/test/Dapr.Workflow.Test/GrpcDurableTaskClientWrapper.cs @@ -1,76 +1,76 @@ -// ------------------------------------------------------------------------ -// Copyright 2025 The Dapr Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------ - -using Dapr.DurableTask; -using Dapr.DurableTask.Client; -using Dapr.DurableTask.Client.Grpc; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Dapr.Workflow.Test; - -/// -/// Wraps the gRPC Durable Task Client to provide an implementation for mocking. -/// -public class GrpcDurableTaskClientWrapper : DurableTaskClient -{ - private GrpcDurableTaskClient grpcClient = new("test", new GrpcDurableTaskClientOptions(), NullLogger.Instance); - - public GrpcDurableTaskClientWrapper() : base("fake") - { - } - - public override ValueTask DisposeAsync() - { - return grpcClient.DisposeAsync(); - } - - public override AsyncPageable GetAllInstancesAsync(OrchestrationQuery? filter = null) - { - return grpcClient.GetAllInstancesAsync(filter); - } - - public override Task GetInstancesAsync(string instanceId, bool getInputsAndOutputs = false, CancellationToken cancellation = default) - { - return grpcClient.GetInstancesAsync(instanceId, getInputsAndOutputs, cancellation); - } - - public override Task RaiseEventAsync(string instanceId, string eventName, object? eventPayload = null, CancellationToken cancellation = default) - { - return grpcClient.RaiseEventAsync(instanceId, eventName, eventPayload, cancellation); - } - - public override Task ResumeInstanceAsync(string instanceId, string? reason = null, CancellationToken cancellation = default) - { - return grpcClient.ResumeInstanceAsync(instanceId, reason, cancellation); - } - - public override Task ScheduleNewOrchestrationInstanceAsync(TaskName orchestratorName, object? input = null, StartOrchestrationOptions? options = null, CancellationToken cancellation = default) - { - return grpcClient.ScheduleNewOrchestrationInstanceAsync(orchestratorName, input, options, cancellation); - } - - public override Task SuspendInstanceAsync(string instanceId, string? reason = null, CancellationToken cancellation = default) - { - return grpcClient.SuspendInstanceAsync(instanceId, reason, cancellation); - } - - public override Task WaitForInstanceCompletionAsync(string instanceId, bool getInputsAndOutputs = false, CancellationToken cancellation = default) - { - return grpcClient.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs, cancellation); - } - - public override Task WaitForInstanceStartAsync(string instanceId, bool getInputsAndOutputs = false, CancellationToken cancellation = default) - { - return grpcClient.WaitForInstanceStartAsync(instanceId, getInputsAndOutputs, cancellation); - } -} +// // ------------------------------------------------------------------------ +// // Copyright 2025 The Dapr Authors +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // http://www.apache.org/licenses/LICENSE-2.0 +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. +// // ------------------------------------------------------------------------ +// +// using Dapr.DurableTask; +// using Dapr.DurableTask.Client; +// using Dapr.DurableTask.Client.Grpc; +// using Microsoft.Extensions.Logging.Abstractions; +// +// namespace Dapr.Workflow.Test; +// +// /// +// /// Wraps the gRPC Durable Task Client to provide an implementation for mocking. +// /// +// public class GrpcDurableTaskClientWrapper : DurableTaskClient +// { +// private GrpcDurableTaskClient grpcClient = new("test", new GrpcDurableTaskClientOptions(), NullLogger.Instance); +// +// public GrpcDurableTaskClientWrapper() : base("fake") +// { +// } +// +// public override ValueTask DisposeAsync() +// { +// return grpcClient.DisposeAsync(); +// } +// +// public override AsyncPageable GetAllInstancesAsync(OrchestrationQuery? filter = null) +// { +// // return grpcClient.GetAllInstancesAsync(filter); +// } +// +// public override Task GetInstancesAsync(string instanceId, bool getInputsAndOutputs = false, CancellationToken cancellation = default) +// { +// return grpcClient.GetInstancesAsync(instanceId, getInputsAndOutputs, cancellation); +// } +// +// public override Task RaiseEventAsync(string instanceId, string eventName, object? eventPayload = null, CancellationToken cancellation = default) +// { +// return grpcClient.RaiseEventAsync(instanceId, eventName, eventPayload, cancellation); +// } +// +// public override Task ResumeInstanceAsync(string instanceId, string? reason = null, CancellationToken cancellation = default) +// { +// return grpcClient.ResumeInstanceAsync(instanceId, reason, cancellation); +// } +// +// public override Task ScheduleNewOrchestrationInstanceAsync(TaskName orchestratorName, object? input = null, StartOrchestrationOptions? options = null, CancellationToken cancellation = default) +// { +// return grpcClient.ScheduleNewOrchestrationInstanceAsync(orchestratorName, input, options, cancellation); +// } +// +// public override Task SuspendInstanceAsync(string instanceId, string? reason = null, CancellationToken cancellation = default) +// { +// return grpcClient.SuspendInstanceAsync(instanceId, reason, cancellation); +// } +// +// public override Task WaitForInstanceCompletionAsync(string instanceId, bool getInputsAndOutputs = false, CancellationToken cancellation = default) +// { +// return grpcClient.WaitForInstanceCompletionAsync(instanceId, getInputsAndOutputs, cancellation); +// } +// +// public override Task WaitForInstanceStartAsync(string instanceId, bool getInputsAndOutputs = false, CancellationToken cancellation = default) +// { +// return grpcClient.WaitForInstanceStartAsync(instanceId, getInputsAndOutputs, cancellation); +// } +// } diff --git a/test/Dapr.Workflow.Test/ParallelExtensionsTest.cs b/test/Dapr.Workflow.Test/ParallelExtensionsTest.cs index a4f38fd84..549555d02 100644 --- a/test/Dapr.Workflow.Test/ParallelExtensionsTest.cs +++ b/test/Dapr.Workflow.Test/ParallelExtensionsTest.cs @@ -11,6 +11,7 @@ // limitations under the License. // ------------------------------------------------------------------------ +using Microsoft.Extensions.Logging; using Moq; namespace Dapr.Workflow.Test; @@ -18,27 +19,10 @@ namespace Dapr.Workflow.Test; /// /// Contains tests for ParallelExtensions.ProcessInParallelAsync method. /// -public class ParallelExtensionsTest +public sealed class ParallelExtensionsTest { private readonly Mock _workflowContextMock = new(); - [Fact] - public async Task ProcessInParallelAsync_WithValidInputs_ShouldProcessAllItemsSuccessfully() - { - // Arrange - var inputs = new[] { 1, 2, 3, 4, 5 }; - var expectedResults = new[] { 2, 4, 6, 8, 10 }; - - // Act - var results = await _workflowContextMock.Object.ProcessInParallelAsync( - inputs, - async input => await Task.FromResult(input * 2), - maxConcurrency: 2); - - // Assert - Assert.Equal(expectedResults, results); - } - [Fact] public async Task ProcessInParallelAsync_WithEmptyInputs_ShouldReturnEmptyArray() { @@ -54,83 +38,6 @@ public async Task ProcessInParallelAsync_WithEmptyInputs_ShouldReturnEmptyArray( Assert.Empty(results); } - [Fact] - public async Task ProcessInParallelAsync_WithSingleInput_ShouldProcessCorrectly() - { - // Arrange - var inputs = new[] { 42 }; - - // Act - var results = await _workflowContextMock.Object.ProcessInParallelAsync( - inputs, - async input => await Task.FromResult(input.ToString())); - - // Assert - Assert.Single(results); - Assert.Equal("42", results[0]); - } - - [Fact] - public async Task ProcessInParallelAsync_WithMaxConcurrency1_ShouldProcessSequentially() - { - // Arrange - var inputs = new[] { 1, 2, 3 }; - var processedOrder = new List(); - var processingTasks = new List(); - - // Act - var results = await _workflowContextMock.Object.ProcessInParallelAsync( - inputs, - async input => - { - processedOrder.Add(input); - await Task.Delay(10); // Small delay to ensure order - return input * 2; - }, - maxConcurrency: 1); - - // Assert - Assert.Equal(new[] { 2, 4, 6 }, results); - Assert.Equal(new[] { 1, 2, 3 }, processedOrder); - } - - [Fact] - public async Task ProcessInParallelAsync_WithHighConcurrency_ShouldRespectConcurrencyLimit() - { - // Arrange - var inputs = Enumerable.Range(1, 100).ToArray(); - var concurrentTasks = 0; - var maxConcurrentTasks = 0; - var lockObj = new object(); - - // Act - var results = await _workflowContextMock.Object.ProcessInParallelAsync( - inputs, - async input => - { - lock (lockObj) - { - concurrentTasks++; - maxConcurrentTasks = Math.Max(maxConcurrentTasks, concurrentTasks); - } - - await Task.Delay(10); - - lock (lockObj) - { - concurrentTasks--; - } - - return input * 2; - }, - maxConcurrency: 10); - - // Assert - Assert.Equal(inputs.Length, results.Length); - Assert.True(maxConcurrentTasks <= 10, $"Expected max concurrent tasks <= 10, but was {maxConcurrentTasks}"); - Assert.True(maxConcurrentTasks >= 1, "At least one task should have been executed"); - } - [Fact] public async Task ProcessInParallelAsync_WithNullContext_ShouldThrowArgumentNullException() { @@ -188,184 +95,161 @@ await Assert.ThrowsAsync( async input => await Task.FromResult(input * 2), maxConcurrency)); } - + [Fact] - public async Task ProcessInParallelAsync_WithTaskFailure_ShouldThrowAggregateException() + public async Task ProcessInParallelAsync_ShouldPreserveInputOrder_WhenTasksCompleteOutOfOrder() { - // Arrange - var inputs = new[] { 1, 2, 3, 4, 5 }; - //var expectedSuccessfulResults = 3; // Items 1, 3, 5 should succeed - const int expectedFailures = 2; // Items 2, 4 should fail + var context = new FakeWorkflowContext(); - // Act & Assert - var aggregateException = await Assert.ThrowsAsync( - async () => await _workflowContextMock.Object.ProcessInParallelAsync( - inputs, - async input => - { - await Task.Delay(10); - if (input % 2 == 0) // Even numbers fail - throw new InvalidOperationException($"Failed processing item {input}"); - return input * 2; - }, - maxConcurrency: 2)); - - // Assert - Assert.Equal(expectedFailures, aggregateException.InnerExceptions.Count); - Assert.All(aggregateException.InnerExceptions, ex => - Assert.IsType(ex)); - Assert.Contains("2 out of 5 tasks failed", aggregateException.Message); - } - - [Fact] - public async Task ProcessInParallelAsync_WithAllTasksFailure_ShouldThrowAggregateExceptionWithAllFailures() - { - // Arrange - var inputs = new[] { 1, 2, 3 }; - var expectedMessage = "Test failure"; + var inputs = new[] { 1, 2, 3, 4 }; - // Act & Assert - var aggregateException = await Assert.ThrowsAsync( - async () => await _workflowContextMock.Object.ProcessInParallelAsync( - inputs, - async input => - { - await Task.Delay(10); - throw new InvalidOperationException($"{expectedMessage} {input}"); - })); + var results = await context.ProcessInParallelAsync( + inputs, + async i => + { + await Task.Delay(i == 1 ? 50 : 1); + return i * 10; + }, + maxConcurrency: 4); - // Assert - Assert.Equal(3, aggregateException.InnerExceptions.Count); - Assert.All(aggregateException.InnerExceptions, ex => - { - Assert.IsType(ex); - Assert.Contains(expectedMessage, ex.Message); - }); + Assert.Equal(new[] { 10, 20, 30, 40 }, results); } - + [Fact] - public async Task ProcessInParallelAsync_WithMixedSuccessAndFailure_ShouldPreserveOrderInResults() + public async Task ProcessInParallelAsync_ShouldEnumerateInputsOnlyOnce() { - // Arrange - var inputs = new[] { 1, 2, 3, 4, 5 }; + var context = new FakeWorkflowContext(); + var trackingInputs = new SingleEnumerationEnumerable(Enumerable.Range(1, 10)); - // Act & Assert - var aggregateException = await Assert.ThrowsAsync( - async () => await _workflowContextMock.Object.ProcessInParallelAsync( - inputs, - async input => - { - await Task.Delay(input * 10); // Different delays to test ordering - if (input == 3) - throw new InvalidOperationException($"Failed on item {input}"); - return input * 2; - }, - maxConcurrency: 2)); + var results = await context.ProcessInParallelAsync( + trackingInputs, + i => Task.FromResult(i * 3), + maxConcurrency: 3); - // Assert that failure occurred - Assert.Single(aggregateException.InnerExceptions); - Assert.Contains("Failed on item 3", aggregateException.InnerExceptions[0].Message); + Assert.Equal(1, trackingInputs.EnumerationCount); + Assert.Equal(Enumerable.Range(1, 10).Select(i => i * 3).ToArray(), results); } [Fact] - public async Task ProcessInParallelAsync_WithDefaultMaxConcurrency_ShouldUseDefaultValue() + public async Task ProcessInParallelAsync_ShouldRespectMaxConcurrency() { // Arrange - var inputs = Enumerable.Range(1, 20).ToArray(); - var concurrentTasks = 0; - var maxConcurrentTasks = 0; + var context = new FakeWorkflowContext(); + var inputs = Enumerable.Range(0, 20).ToArray(); + const int maxConcurrency = 5; + int currentConcurrency = 0; + int maxObservedConcurrency = 0; var lockObj = new object(); // Act - var results = await _workflowContextMock.Object.ProcessInParallelAsync( - inputs, - async input => - { - lock (lockObj) + await context.ProcessInParallelAsync( + inputs, + async _ => { - concurrentTasks++; - maxConcurrentTasks = Math.Max(maxConcurrentTasks, concurrentTasks); - } - - await Task.Delay(50); // Longer delay to ensure concurrency + lock (lockObj) + { + currentConcurrency++; + if (currentConcurrency > maxObservedConcurrency) + { + maxObservedConcurrency = currentConcurrency; + } + } + + // Simulate work that takes time to allow concurrency to build up + await Task.Delay(10); - lock (lockObj) - { - concurrentTasks--; - } + lock (lockObj) + { + currentConcurrency--; + } - return input * 2; - }); // Using default maxConcurrency (should be 5) + return 0; + }, + maxConcurrency); // Assert - Assert.Equal(inputs.Length, results.Length); - Assert.True(maxConcurrentTasks <= 5, $"Expected max concurrent tasks <= 5, but was {maxConcurrentTasks}"); - Assert.True(maxConcurrentTasks >= 1, "At least one task should have been executed"); + Assert.True(maxObservedConcurrency <= maxConcurrency, + $"Max concurrency observed ({maxObservedConcurrency}) exceeded limit ({maxConcurrency})"); + Assert.True(maxObservedConcurrency > 1, "Expected parallelism did not occur"); } [Fact] - public async Task ProcessInParallelAsync_WithDifferentInputAndOutputTypes_ShouldHandleTypeConversion() + public async Task ProcessInParallelAsync_ShouldAggregateExceptions_WhenTasksFail() { // Arrange - var inputs = new[] { "1", "2", "3", "4", "5" }; - var expectedResults = new[] { 1, 2, 3, 4, 5 }; - - // Act - var results = await _workflowContextMock.Object.ProcessInParallelAsync( - inputs, - async input => await Task.FromResult(int.Parse(input)), - maxConcurrency: 3); + var context = new FakeWorkflowContext(); + var inputs = new[] { 1, 2, 3, 4, 5 }; + var expectedMessage = "Test exception"; - // Assert - Assert.Equal(expectedResults, results); + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + await context.ProcessInParallelAsync( + inputs, + async i => + { + if (i % 2 == 0) // Fail on even numbers + { + await Task.Yield(); + throw new InvalidOperationException($"{expectedMessage} {i}"); + } + return i; + }, + maxConcurrency: 2)); + + Assert.Equal(2, ex.InnerExceptions.Count); + Assert.All(ex.InnerExceptions, e => Assert.IsType(e)); } [Fact] - public async Task ProcessInParallelAsync_WithComplexObjects_ShouldProcessCorrectly() + public async Task ProcessInParallelAsync_ShouldAggregateExceptions_WhenFactoryThrowsSynchronously() { // Arrange - var inputs = new[] - { - new TestInput { Id = 1, Value = "Test1" }, - new TestInput { Id = 2, Value = "Test2" }, - new TestInput { Id = 3, Value = "Test3" } - }; - - // Act - var results = await _workflowContextMock.Object.ProcessInParallelAsync( - inputs, - async input => await Task.FromResult(new TestOutput - { - ProcessedId = input.Id * 10, - ProcessedValue = input.Value.ToUpper() - }), - maxConcurrency: 2); + var context = new FakeWorkflowContext(); + var inputs = new[] { 1, 2, 3 }; - // Assert - Assert.Equal(3, results.Length); - Assert.Equal(10, results[0].ProcessedId); - Assert.Equal("TEST1", results[0].ProcessedValue); - Assert.Equal(20, results[1].ProcessedId); - Assert.Equal("TEST2", results[1].ProcessedValue); - Assert.Equal(30, results[2].ProcessedId); - Assert.Equal("TEST3", results[2].ProcessedValue); + // Act & Assert + var ex = await Assert.ThrowsAsync(async () => + await context.ProcessInParallelAsync( + inputs, + i => + { + if (i == 2) + { + throw new InvalidOperationException("Sync factory failure"); + } + return Task.FromResult(i); + }, + maxConcurrency: 2)); + + Assert.Single(ex.InnerExceptions); + Assert.IsType(ex.InnerExceptions[0]); + Assert.Equal("Sync factory failure", ex.InnerExceptions[0].Message); } [Fact] - public async Task ProcessInParallelAsync_WithLargeDataset_ShouldHandleEfficiently() + public async Task ProcessInParallelAsync_WithInputCountGreaterThanMaxConcurrency_ShouldProcessAll() { // Arrange - var inputs = Enumerable.Range(1, 1000).ToArray(); - var expectedResults = inputs.Select(x => x * 2).ToArray(); + var context = new FakeWorkflowContext(); + var count = 10; + var inputs = Enumerable.Range(0, count).ToArray(); + var processedCount = 0; // Act - var results = await _workflowContextMock.Object.ProcessInParallelAsync( - inputs, - async input => await Task.FromResult(input * 2), - maxConcurrency: 10); + var results = await context.ProcessInParallelAsync( + inputs, + async i => + { + await Task.Yield(); + Interlocked.Increment(ref processedCount); + return i; + }, + maxConcurrency: 2); // Significantly smaller than input count // Assert - Assert.Equal(expectedResults, results); + Assert.Equal(count, results.Length); + Assert.Equal(count, processedCount); + Assert.Equal(inputs, results); } private class TestInput @@ -379,4 +263,38 @@ private class TestOutput public int ProcessedId { get; set; } public string ProcessedValue { get; set; } = string.Empty; } + + private sealed class FakeWorkflowContext : WorkflowContext + { + public override string Name => "wf"; + public override string InstanceId => "i"; + public override DateTime CurrentUtcDateTime => DateTime.UtcNow; + public override bool IsReplaying => false; + + public override Task CallActivityAsync(string name, object? input = null, WorkflowTaskOptions? options = null) => throw new NotSupportedException(); + public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken) => throw new NotSupportedException(); + public override Task WaitForExternalEventAsync(string eventName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public override Task WaitForExternalEventAsync(string eventName, TimeSpan timeout) => throw new NotSupportedException(); + public override void SendEvent(string instanceId, string eventName, object payload) => throw new NotSupportedException(); + public override void SetCustomStatus(object? customStatus) => throw new NotSupportedException(); + public override Task CallChildWorkflowAsync(string workflowName, object? input = null, ChildWorkflowTaskOptions? options = null) => throw new NotSupportedException(); + public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) => throw new NotSupportedException(); + public override Guid NewGuid() => Guid.NewGuid(); + public override ILogger CreateReplaySafeLogger(string categoryName) => Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + public override ILogger CreateReplaySafeLogger(Type type) => Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + public override ILogger CreateReplaySafeLogger() => Microsoft.Extensions.Logging.Abstractions.NullLogger.Instance; + } + + private sealed class SingleEnumerationEnumerable(IEnumerable inner) : IEnumerable + { + public int EnumerationCount { get; private set; } + + public IEnumerator GetEnumerator() + { + EnumerationCount++; + return inner.GetEnumerator(); + } + + System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => GetEnumerator(); + } } diff --git a/test/Dapr.Workflow.Test/Serialziation/JsonWorkflowSerializerTests.cs b/test/Dapr.Workflow.Test/Serialziation/JsonWorkflowSerializerTests.cs new file mode 100644 index 000000000..88834fed4 --- /dev/null +++ b/test/Dapr.Workflow.Test/Serialziation/JsonWorkflowSerializerTests.cs @@ -0,0 +1,190 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Text.Json; +using Dapr.Workflow.Serialization; + +namespace Dapr.Workflow.Test.Serialziation; + +public class JsonWorkflowSerializerTests +{ + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenOptionsIsNull() + { + Assert.Throws(() => new JsonWorkflowSerializer(null!)); + } + + [Fact] + public void Serialize_ShouldReturnEmptyString_WhenValueIsNull() + { + var serializer = new JsonWorkflowSerializer(); + + var json = serializer.Serialize(null); + + Assert.Equal(string.Empty, json); + } + + [Fact] + public void Deserialize_Generic_ShouldReturnDefault_WhenDataIsNull() + { + var serializer = new JsonWorkflowSerializer(); + + var value = serializer.Deserialize(null); + + Assert.Equal(default, value); + } + + [Fact] + public void Deserialize_Generic_ShouldReturnDefault_WhenDataIsEmpty() + { + var serializer = new JsonWorkflowSerializer(); + + var value = serializer.Deserialize(string.Empty); + + Assert.Null(value); + } + + [Fact] + public void Deserialize_NonGeneric_ShouldThrowArgumentNullException_WhenReturnTypeIsNull() + { + var serializer = new JsonWorkflowSerializer(); + + Assert.Throws(() => serializer.Deserialize("{}", null!)); + } + + [Fact] + public void Deserialize_NonGeneric_ShouldReturnNull_WhenDataIsNull() + { + var serializer = new JsonWorkflowSerializer(); + + var value = serializer.Deserialize(null, typeof(SimplePayload)); + + Assert.Null(value); + } + + [Fact] + public void Deserialize_NonGeneric_ShouldReturnNull_WhenDataIsEmpty() + { + var serializer = new JsonWorkflowSerializer(); + + var value = serializer.Deserialize(string.Empty, typeof(SimplePayload)); + + Assert.Null(value); + } + + [Fact] + public void Serialize_DefaultOptions_ShouldUseCamelCasePropertyNames() + { + var serializer = new JsonWorkflowSerializer(); + + var json = serializer.Serialize(new SimplePayload { FirstName = "Ada" }); + + Assert.Contains("\"firstName\"", json); + Assert.DoesNotContain("\"FirstName\"", json); + } + + [Fact] + public void Serialize_CustomOptions_ShouldRespectPropertyNamingPolicy() + { + var options = new JsonSerializerOptions + { + PropertyNamingPolicy = null + }; + var serializer = new JsonWorkflowSerializer(options); + + var json = serializer.Serialize(new SimplePayload { FirstName = "Ada" }); + + Assert.Contains("\"FirstName\"", json); + Assert.DoesNotContain("\"firstName\"", json); + } + + [Fact] + public void Serialize_WithInputType_ShouldSerializeUsingProvidedTypeHint() + { + var serializer = new JsonWorkflowSerializer(); + + var value = new DerivedPayload { BaseValue = "base", ExtraValue = "extra" }; + + var jsonAsRuntimeType = serializer.Serialize(value); + var jsonAsBaseType = serializer.Serialize(value, typeof(BasePayload)); + + Assert.Contains("extraValue", jsonAsRuntimeType); + Assert.DoesNotContain("extraValue", jsonAsBaseType); + Assert.Contains("baseValue", jsonAsBaseType); + } + + [Fact] + public void SerializeAndDeserialize_Generic_ShouldRoundTripObject() + { + var serializer = new JsonWorkflowSerializer(); + + var original = new ComplexPayload + { + Id = 123, + Name = "workflow", + Nested = new SimplePayload { FirstName = "Ada" } + }; + + var json = serializer.Serialize(original); + var roundTripped = serializer.Deserialize(json); + + Assert.NotNull(roundTripped); + Assert.Equal(original.Id, roundTripped!.Id); + Assert.Equal(original.Name, roundTripped.Name); + Assert.NotNull(roundTripped.Nested); + Assert.Equal(original.Nested.FirstName, roundTripped.Nested.FirstName); + } + + [Fact] + public void Deserialize_NonGeneric_ShouldReturnExpectedObject_WhenDataIsValid() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var json = "{\"firstName\":\"Ada\"}"; + + var obj = serializer.Deserialize(json, typeof(SimplePayload)); + + Assert.NotNull(obj); + var payload = Assert.IsType(obj); + Assert.Equal("Ada", payload.FirstName); + } + + [Fact] + public void Deserialize_NonGeneric_ShouldThrowJsonException_WhenDataIsInvalidJson() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + Assert.ThrowsAny(() => serializer.Deserialize("{not-json", typeof(SimplePayload))); + } + + private sealed class SimplePayload + { + public string? FirstName { get; set; } + } + + private class BasePayload + { + public string? BaseValue { get; set; } + } + + private sealed class DerivedPayload : BasePayload + { + public string? ExtraValue { get; set; } + } + + private sealed class ComplexPayload + { + public int Id { get; set; } + public string? Name { get; set; } + public SimplePayload? Nested { get; set; } + } +} diff --git a/test/Dapr.Workflow.Test/Worker/Grpc/GrpcProtocolHandlerTests.cs b/test/Dapr.Workflow.Test/Worker/Grpc/GrpcProtocolHandlerTests.cs new file mode 100644 index 000000000..229baa310 --- /dev/null +++ b/test/Dapr.Workflow.Test/Worker/Grpc/GrpcProtocolHandlerTests.cs @@ -0,0 +1,673 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Worker.Grpc; +using Grpc.Core; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Dapr.Workflow.Test.Worker.Grpc; + +public class GrpcProtocolHandlerTests +{ + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenLoggerFactoryIsNull() + { + var grpcClient = CreateGrpcClientMock().Object; + + Assert.Throws(() => new GrpcProtocolHandler(grpcClient, null!)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Constructor_ShouldThrowArgumentOutOfRangeException_WhenMaxConcurrentWorkItemsIsNotPositive(int value) + { + var grpcClient = CreateGrpcClientMock().Object; + + Assert.Throws(() => new GrpcProtocolHandler(grpcClient, NullLoggerFactory.Instance, value, 1)); + } + + [Theory] + [InlineData(0)] + [InlineData(-1)] + public void Constructor_ShouldThrowArgumentOutOfRangeException_WhenMaxConcurrentActivitiesIsNotPositive(int value) + { + var grpcClient = CreateGrpcClientMock().Object; + + Assert.Throws(() => new GrpcProtocolHandler(grpcClient, NullLoggerFactory.Instance, 1, value)); + } + + [Fact] + public async Task StartAsync_ShouldCompleteOrchestratorTask_ForOrchestratorWorkItem() + { + var grpcClientMock = CreateGrpcClientMock(); + + var workItems = new[] + { + new WorkItem + { + OrchestratorRequest = new OrchestratorRequest { InstanceId = "i-1" } + } + }; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCall(workItems)); + + OrchestratorResponse? completed = null; + grpcClientMock + .Setup(x => x.CompleteOrchestratorTaskAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => completed = r) + .Returns(CreateAsyncUnaryCall(new CompleteTaskResponse())); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + await handler.StartAsync( + workflowHandler: req => Task.FromResult(new OrchestratorResponse { InstanceId = req.InstanceId }), + activityHandler: _ => Task.FromResult(new ActivityResponse()), + cancellationToken: CancellationToken.None); + + Assert.NotNull(completed); + Assert.Equal("i-1", completed!.InstanceId); + } + + [Fact] + public async Task StartAsync_ShouldCompleteActivityTask_ForActivityWorkItem() + { + var grpcClientMock = CreateGrpcClientMock(); + + var workItems = new[] + { + new WorkItem + { + ActivityRequest = new ActivityRequest + { + Name = "act", + TaskId = 42, + OrchestrationInstance = new OrchestrationInstance { InstanceId = "i-2" } + } + } + }; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCall(workItems)); + + ActivityResponse? completed = null; + grpcClientMock + .Setup(x => x.CompleteActivityTaskAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => completed = r) + .Returns(CreateAsyncUnaryCall(new CompleteTaskResponse())); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + await handler.StartAsync( + workflowHandler: _ => Task.FromResult(new OrchestratorResponse()), + activityHandler: req => Task.FromResult(new ActivityResponse { InstanceId = req.OrchestrationInstance.InstanceId, TaskId = req.TaskId, Result = "ok" }), + cancellationToken: CancellationToken.None); + + Assert.NotNull(completed); + Assert.Equal("i-2", completed!.InstanceId); + Assert.Equal(42, completed.TaskId); + Assert.Equal("ok", completed.Result); + } + + [Fact] + public async Task StartAsync_ShouldSendFailureResult_WhenOrchestratorHandlerThrows() + { + var grpcClientMock = CreateGrpcClientMock(); + + var workItems = new[] + { + new WorkItem + { + OrchestratorRequest = new OrchestratorRequest { InstanceId = "i-err" } + } + }; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCall(workItems)); + + OrchestratorResponse? completed = null; + grpcClientMock + .Setup(x => x.CompleteOrchestratorTaskAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => completed = r) + .Returns(CreateAsyncUnaryCall(new CompleteTaskResponse())); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + await handler.StartAsync( + workflowHandler: _ => throw new InvalidOperationException("boom"), + activityHandler: _ => Task.FromResult(new ActivityResponse()), + cancellationToken: CancellationToken.None); + + Assert.NotNull(completed); + Assert.Equal("i-err", completed!.InstanceId); + Assert.Single(completed.Actions); + Assert.NotNull(completed.Actions[0].CompleteOrchestration); + Assert.Equal(OrchestrationStatus.Failed, completed.Actions[0].CompleteOrchestration.OrchestrationStatus); + Assert.NotNull(completed.Actions[0].CompleteOrchestration.FailureDetails); + Assert.Contains("boom", completed.Actions[0].CompleteOrchestration.FailureDetails.ErrorMessage); + } + + [Fact] + public async Task DisposeAsync_ShouldBeIdempotent() + { + var grpcClientMock = CreateGrpcClientMock(); + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + await handler.DisposeAsync(); + await handler.DisposeAsync(); + } + + [Fact] + public async Task StartAsync_ShouldSendGetWorkItemsRequest_WithConfiguredConcurrencyLimits() + { + var grpcClientMock = CreateGrpcClientMock(); + + GetWorkItemsRequest? captured = null; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns((GetWorkItemsRequest r, Metadata? _, DateTime? __, CancellationToken ___) => + { + captured = r; + return CreateServerStreamingCall(Array.Empty()); + }); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance, maxConcurrentWorkItems: 7, maxConcurrentActivities: 9); + + await handler.StartAsync( + workflowHandler: _ => Task.FromResult(new OrchestratorResponse()), + activityHandler: _ => Task.FromResult(new ActivityResponse()), + cancellationToken: CancellationToken.None); + + Assert.NotNull(captured); + Assert.Equal(7, captured!.MaxConcurrentOrchestrationWorkItems); + Assert.Equal(9, captured.MaxConcurrentActivityWorkItems); + } + + [Fact] + public async Task StartAsync_ShouldReturnWithoutThrowing_WhenGrpcStreamIsCancelled() + { + var grpcClientMock = CreateGrpcClientMock(); + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Throws(new RpcException(new Status(StatusCode.Cancelled, "cancelled"))); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + await handler.StartAsync( + workflowHandler: _ => Task.FromResult(new OrchestratorResponse()), + activityHandler: _ => Task.FromResult(new ActivityResponse()), + cancellationToken: CancellationToken.None); + } + + [Fact] + public async Task StartAsync_ShouldSendActivityFailureResult_WhenActivityHandlerThrows() + { + var grpcClientMock = CreateGrpcClientMock(); + + var workItems = new[] + { + new WorkItem + { + ActivityRequest = new ActivityRequest + { + Name = "act", + TaskId = 123, + OrchestrationInstance = new OrchestrationInstance { InstanceId = "i-1" } + } + } + }; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCall(workItems)); + + ActivityResponse? sent = null; + grpcClientMock + .Setup(x => x.CompleteActivityTaskAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => sent = r) + .Returns(CreateAsyncUnaryCall(new CompleteTaskResponse())); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + await handler.StartAsync( + workflowHandler: _ => Task.FromResult(new OrchestratorResponse()), + activityHandler: _ => throw new InvalidOperationException("boom"), + cancellationToken: CancellationToken.None); + + Assert.NotNull(sent); + Assert.Equal("i-1", sent!.InstanceId); + Assert.Equal(0, sent.TaskId); + Assert.NotNull(sent.FailureDetails); + Assert.Contains(nameof(InvalidOperationException), sent.FailureDetails.ErrorType); + Assert.Contains("boom", sent.FailureDetails.ErrorMessage); + } + + [Fact] + public async Task StartAsync_ShouldNotThrow_WhenSendingActivityFailureResultAlsoThrows() + { + var grpcClientMock = CreateGrpcClientMock(); + + var workItems = new[] + { + new WorkItem + { + ActivityRequest = new ActivityRequest + { + Name = "act", + TaskId = 123, + OrchestrationInstance = new OrchestrationInstance { InstanceId = "i-1" } + } + } + }; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCall(workItems)); + + grpcClientMock + .Setup(x => x.CompleteActivityTaskAsync(It.IsAny(), null, null, It.IsAny())) + .Throws(new RpcException(new Status(StatusCode.Unavailable, "nope"))); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + await handler.StartAsync( + workflowHandler: _ => Task.FromResult(new OrchestratorResponse()), + activityHandler: _ => throw new Exception("boom"), + cancellationToken: CancellationToken.None); + } + + [Fact] + public async Task StartAsync_ShouldUseCreateActivityFailureResult_WithNullStackTrace_WhenExceptionStackTraceIsNull() + { + var grpcClientMock = CreateGrpcClientMock(); + + var workItems = new[] + { + new WorkItem + { + ActivityRequest = new ActivityRequest + { + Name = "act", + TaskId = 123, + OrchestrationInstance = new OrchestrationInstance { InstanceId = "i-1" } + } + } + }; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCall(workItems)); + + ActivityResponse? sent = null; + grpcClientMock + .Setup(x => x.CompleteActivityTaskAsync(It.IsAny(), null, null, It.IsAny())) + .Callback((r, _, _, _) => sent = r) + .Returns(CreateAsyncUnaryCall(new CompleteTaskResponse())); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + await handler.StartAsync( + workflowHandler: _ => Task.FromResult(new OrchestratorResponse()), + activityHandler: _ => throw new NullStackTraceException("boom"), + cancellationToken: CancellationToken.None); + + Assert.NotNull(sent); + Assert.NotNull(sent!.FailureDetails); + Assert.Null(sent.FailureDetails.StackTrace); + Assert.Contains("boom", sent.FailureDetails.ErrorMessage); + } + + [Fact] + public async Task StartAsync_ShouldNotCallCompleteActivityTask_WhenActivityHandlerThrowsOperationCanceledException_AndTokenIsCanceled() + { + var grpcClientMock = CreateGrpcClientMock(); + + var workItems = new[] + { + new WorkItem + { + ActivityRequest = new ActivityRequest + { + Name = "act", + TaskId = 1, + OrchestrationInstance = new OrchestrationInstance { InstanceId = "i-1" } + } + } + }; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCallIgnoringCancellation(workItems)); + + var completeCalled = false; + grpcClientMock + .Setup(x => x.CompleteActivityTaskAsync(It.IsAny(), null, null, It.IsAny())) + .Callback(() => completeCalled = true) + .Returns(CreateAsyncUnaryCall(new CompleteTaskResponse())); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + using var cts = new CancellationTokenSource(); + + await handler.StartAsync( + workflowHandler: _ => Task.FromResult(new OrchestratorResponse()), + activityHandler: _ => + { + cts.Cancel(); // make StartAsync's linked token "IsCancellationRequested == true" + throw new OperationCanceledException(cts.Token); + }, + cancellationToken: cts.Token); + + Assert.False(completeCalled); + } + + [Fact] + public async Task StartAsync_ShouldNotCallCompleteOrchestratorTask_WhenWorkflowHandlerThrowsOperationCanceledException_AndTokenIsCanceled() + { + var grpcClientMock = CreateGrpcClientMock(); + + var workItems = new[] + { + new WorkItem + { + OrchestratorRequest = new OrchestratorRequest { InstanceId = "i-1" } + } + }; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCallIgnoringCancellation(workItems)); + + var completeCalled = false; + grpcClientMock + .Setup(x => x.CompleteOrchestratorTaskAsync(It.IsAny(), null, null, It.IsAny())) + .Callback(() => completeCalled = true) + .Returns(CreateAsyncUnaryCall(new CompleteTaskResponse())); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + using var cts = new CancellationTokenSource(); + + await handler.StartAsync( + workflowHandler: _ => + { + cts.Cancel(); + throw new OperationCanceledException(cts.Token); + }, + activityHandler: _ => Task.FromResult(new ActivityResponse()), + cancellationToken: cts.Token); + + Assert.False(completeCalled); + } + + [Fact] + public async Task StartAsync_ShouldCleanupCompletedActiveWorkItems_WhenActiveWorkItemsListGrows() + { + var grpcClientMock = CreateGrpcClientMock(); + + // With maxConcurrentWorkItems=1, cleanup threshold is > 2. + // We send 3 work items that complete quickly so RemoveAll(t => t.IsCompleted) is executed. + var workItems = new[] + { + new WorkItem { ActivityRequest = new ActivityRequest { Name = "a1", TaskId = 1, OrchestrationInstance = new OrchestrationInstance { InstanceId = "i" } } }, + new WorkItem { ActivityRequest = new ActivityRequest { Name = "a2", TaskId = 2, OrchestrationInstance = new OrchestrationInstance { InstanceId = "i" } } }, + new WorkItem { ActivityRequest = new ActivityRequest { Name = "a3", TaskId = 3, OrchestrationInstance = new OrchestrationInstance { InstanceId = "i" } } }, + }; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCall(workItems)); + + var completedCount = 0; + grpcClientMock + .Setup(x => x.CompleteActivityTaskAsync(It.IsAny(), null, null, It.IsAny())) + .Callback(() => Interlocked.Increment(ref completedCount)) + .Returns(CreateAsyncUnaryCall(new CompleteTaskResponse())); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance, maxConcurrentWorkItems: 1, maxConcurrentActivities: 1); + + await handler.StartAsync( + workflowHandler: _ => Task.FromResult(new OrchestratorResponse()), + activityHandler: req => Task.FromResult(new ActivityResponse { InstanceId = req.OrchestrationInstance.InstanceId, TaskId = req.TaskId, Result = "ok" }), + cancellationToken: CancellationToken.None); + + Assert.Equal(3, completedCount); + } + + [Fact] + public async Task StartAsync_ShouldNotThrow_WhenWorkflowHandlerThrows_AndSendingFailureResultAlsoThrows() + { + var grpcClientMock = CreateGrpcClientMock(); + + var workItems = new[] + { + new WorkItem + { + OrchestratorRequest = new OrchestratorRequest { InstanceId = "i-1" } + } + }; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCall(workItems)); + + grpcClientMock + .Setup(x => x.CompleteOrchestratorTaskAsync(It.IsAny(), null, null, It.IsAny())) + .Throws(new RpcException(new Status(StatusCode.Unavailable, "nope"))); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + await handler.StartAsync( + workflowHandler: _ => throw new InvalidOperationException("boom"), + activityHandler: _ => Task.FromResult(new ActivityResponse()), + cancellationToken: CancellationToken.None); + } + + [Fact] + public async Task StartAsync_ShouldHandleUnknownWorkItemType_AndWaitForActiveTasks() + { + var grpcClientMock = CreateGrpcClientMock(); + + var workItems = new[] + { + new WorkItem() // RequestCase = None + }; + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCall(workItems)); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + await handler.StartAsync( + workflowHandler: _ => Task.FromResult(new OrchestratorResponse()), + activityHandler: _ => Task.FromResult(new ActivityResponse()), + cancellationToken: CancellationToken.None); + } + + [Fact] + public async Task StartAsync_ShouldRethrow_WhenReceiveLoopThrowsBeforeAnyItemsAreRead() + { + var grpcClientMock = CreateGrpcClientMock(); + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCallFromReader(new ThrowingAsyncStreamReader(new InvalidOperationException("boom")))); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + var ex = await Assert.ThrowsAsync(() => + handler.StartAsync( + workflowHandler: _ => Task.FromResult(new OrchestratorResponse()), + activityHandler: _ => Task.FromResult(new ActivityResponse()), + cancellationToken: CancellationToken.None)); + + Assert.Contains("boom", ex.Message); + } + + [Fact] + public async Task StartAsync_ShouldRethrow_WhenReceiveLoopThrowsAfterFirstItemIsRead() + { + var grpcClientMock = CreateGrpcClientMock(); + + var reader = new ThrowingAfterOneAsyncStreamReader( + first: new WorkItem(), // RequestCase = None => Task.Run branch + thenThrow: new InvalidOperationException("boom-after-one")); + + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCallFromReader(reader)); + + var handler = new GrpcProtocolHandler(grpcClientMock.Object, NullLoggerFactory.Instance); + + var ex = await Assert.ThrowsAsync(() => + handler.StartAsync( + workflowHandler: _ => Task.FromResult(new OrchestratorResponse()), + activityHandler: _ => Task.FromResult(new ActivityResponse()), + cancellationToken: CancellationToken.None)); + + Assert.Contains("boom-after-one", ex.Message); + } + + private static AsyncServerStreamingCall CreateServerStreamingCallFromReader(IAsyncStreamReader reader) + { + return new AsyncServerStreamingCall( + reader, + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => [], + () => { }); + } + + private sealed class ThrowingAsyncStreamReader(Exception ex) : IAsyncStreamReader + { + public WorkItem Current => new(); + + public Task MoveNext(CancellationToken cancellationToken) + { + throw ex; + } + } + + private sealed class ThrowingAfterOneAsyncStreamReader(WorkItem first, Exception thenThrow) : IAsyncStreamReader + { + private int _state; // 0 = before first, 1 = after first (throw), 2 = done (never reached) + + public WorkItem Current { get; private set; } = new(); + + public Task MoveNext(CancellationToken cancellationToken) + { + if (_state == 0) + { + _state = 1; + Current = first; + return Task.FromResult(true); + } + + throw thenThrow; + } + } + + private static AsyncServerStreamingCall CreateServerStreamingCallIgnoringCancellation(IEnumerable items) + { + var stream = new TestAsyncStreamReaderIgnoringCancellation(items); + + return new AsyncServerStreamingCall( + stream, + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => [], + () => { }); + } + + private sealed class TestAsyncStreamReaderIgnoringCancellation(IEnumerable items) : IAsyncStreamReader + { + private readonly IEnumerator _enumerator = items.GetEnumerator(); + + public WorkItem Current { get; private set; } = new(); + + public Task MoveNext(CancellationToken cancellationToken) + { + // Intentionally ignore cancellationToken to allow testing of the + // OperationCanceledException paths inside ProcessWorkflowAsync/ProcessActivityAsync. + var moved = _enumerator.MoveNext(); + if (moved) + { + Current = _enumerator.Current; + } + + return Task.FromResult(moved); + } + } + + private sealed class NullStackTraceException(string message) : Exception(message) + { + public override string? StackTrace => null; + } + + private static Mock CreateGrpcClientMock() + { + var callInvoker = new Mock(MockBehavior.Loose); + return new Mock(MockBehavior.Loose, callInvoker.Object); + } + + private static AsyncServerStreamingCall CreateServerStreamingCall(IEnumerable items) + { + var stream = new TestAsyncStreamReader(items); + + return new AsyncServerStreamingCall( + stream, + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => [], + () => { }); + } + + private static AsyncUnaryCall CreateAsyncUnaryCall(CompleteTaskResponse response) + { + return new AsyncUnaryCall( + Task.FromResult(response), + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => [], + () => { }); + } + + private sealed class TestAsyncStreamReader(IEnumerable items) : IAsyncStreamReader + { + private readonly IEnumerator _enumerator = items.GetEnumerator(); + + public WorkItem Current { get; private set; } = new(); + + public Task MoveNext(CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + + var moved = _enumerator.MoveNext(); + if (moved) + { + Current = _enumerator.Current; + } + + return Task.FromResult(moved); + } + } +} diff --git a/test/Dapr.Workflow.Test/Worker/Internal/ReplaySafeLoggerTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/ReplaySafeLoggerTests.cs new file mode 100644 index 000000000..82d428ad7 --- /dev/null +++ b/test/Dapr.Workflow.Test/Worker/Internal/ReplaySafeLoggerTests.cs @@ -0,0 +1,116 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Workflow.Worker.Internal; +using Microsoft.Extensions.Logging; +using Moq; + +namespace Dapr.Workflow.Test.Worker.Internal; + +public class ReplaySafeLoggerTests +{ + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenInnerLoggerIsNull() + { + Assert.Throws(() => new ReplaySafeLogger(null!, () => false)); + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenIsReplayingFuncIsNull() + { + var inner = Mock.Of(); + Assert.Throws(() => new ReplaySafeLogger(inner, null!)); + } + + [Fact] + public void BeginScope_ShouldForwardToInnerLogger_RegardlessOfReplayState() + { + var scope = Mock.Of(); + + var innerMock = new Mock(MockBehavior.Strict); + innerMock + .Setup(x => x.BeginScope(It.IsAny())) + .Returns(scope); + + var logger = new ReplaySafeLogger(innerMock.Object, () => true); + + var returned = logger.BeginScope("scope-state"); + + Assert.Same(scope, returned); + + innerMock.Verify(x => x.BeginScope(It.IsAny()), Times.Once); + } + + [Fact] + public void IsEnabled_ShouldReturnFalse_WhenReplayingEvenIfInnerIsEnabled() + { + var innerMock = new Mock(MockBehavior.Strict); + innerMock.Setup(x => x.IsEnabled(LogLevel.Information)).Returns(true); + + var logger = new ReplaySafeLogger(innerMock.Object, () => true); + + Assert.False(logger.IsEnabled(LogLevel.Information)); + } + + [Fact] + public void IsEnabled_ShouldReturnInnerResult_WhenNotReplaying() + { + var innerMock = new Mock(MockBehavior.Strict); + innerMock.Setup(x => x.IsEnabled(LogLevel.Debug)).Returns(false); + + var logger = new ReplaySafeLogger(innerMock.Object, () => false); + + Assert.False(logger.IsEnabled(LogLevel.Debug)); + } + + [Fact] + public void Log_ShouldNotCallInnerLogger_WhenReplaying() + { + var innerMock = new Mock(MockBehavior.Strict); + + var logger = new ReplaySafeLogger(innerMock.Object, () => true); + + logger.Log(LogLevel.Information, new EventId(1, "e"), "state", null, (s, _) => s); + + innerMock.Verify( + x => x.Log(It.IsAny(), It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny>()), + Times.Never); + } + + [Fact] + public void Log_ShouldCallInnerLogger_WhenNotReplaying() + { + var innerMock = new Mock(MockBehavior.Strict); + innerMock + .Setup(x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>())); + + var logger = new ReplaySafeLogger(innerMock.Object, () => false); + + logger.Log(LogLevel.Warning, new EventId(2, "warn"), "state", null, (s, _) => s); + + innerMock.Verify( + x => x.Log( + LogLevel.Warning, + It.IsAny(), + It.IsAny(), + It.IsAny(), + It.IsAny>()), + Times.Once); + } +} diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowActivityContextImplTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowActivityContextImplTests.cs new file mode 100644 index 000000000..92683b6fa --- /dev/null +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowActivityContextImplTests.cs @@ -0,0 +1,39 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Workflow.Abstractions; +using Dapr.Workflow.Worker.Internal; + +namespace Dapr.Workflow.Test.Worker.Internal; + +public class WorkflowActivityContextImplTests +{ + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenInstanceIdIsNull() + { + var identifier = new TaskIdentifier("ActivityA"); + + Assert.Throws(() => new WorkflowActivityContextImpl(identifier, null!, "tek")); + } + + [Fact] + public void Properties_ShouldExposeIdentifierAndInstanceId() + { + var identifier = new TaskIdentifier("ActivityA"); + var context = new WorkflowActivityContextImpl(identifier, "instance-1", "tek"); + + Assert.Equal(identifier, context.Identifier); + Assert.Equal("instance-1", context.InstanceId); + Assert.Equal("tek", context.TaskExecutionKey); + } +} diff --git a/test/Dapr.Workflow.Test/Worker/Internal/WorkflowOrchestrationContextTests.cs b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowOrchestrationContextTests.cs new file mode 100644 index 000000000..e1ce8624c --- /dev/null +++ b/test/Dapr.Workflow.Test/Worker/Internal/WorkflowOrchestrationContextTests.cs @@ -0,0 +1,708 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Text.Json; +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Serialization; +using Dapr.Workflow.Worker.Internal; +using Microsoft.Extensions.Logging.Abstractions; +using Newtonsoft.Json; +using JsonException = System.Text.Json.JsonException; + +namespace Dapr.Workflow.Test.Worker.Internal; + +public class WorkflowOrchestrationContextTests +{ + [Fact] + public void CallActivityAsync_ShouldScheduleTaskAction_WhenNotReplaying() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: [], + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var task = context.CallActivityAsync("DoWork", input: new { Value = 3 }); + + Assert.NotNull(task); + Assert.Single(context.PendingActions); + + var action = context.PendingActions[0]; + Assert.NotNull(action.ScheduleTask); + Assert.Equal("DoWork", action.ScheduleTask.Name); + Assert.Contains("\"value\":3", action.ScheduleTask.Input); + } + + [Fact] + public async Task CallActivityAsync_ShouldReturnCompletedResult_FromHistoryTaskCompleted() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + TaskCompleted = new TaskCompletedEvent { Result = "\"hello\"" } + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var result = await context.CallActivityAsync("Any"); + + Assert.Equal("hello", result); + Assert.Empty(context.PendingActions); + } + + [Fact] + public async Task CallActivityAsync_ShouldThrowWorkflowTaskFailedException_FromHistoryTaskFailed() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + TaskFailed = new TaskFailedEvent + { + FailureDetails = new TaskFailureDetails + { + ErrorType = "MyError", + ErrorMessage = "Boom", + StackTrace = "trace" + } + } + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + await Assert.ThrowsAsync(() => context.CallActivityAsync("Any")); + Assert.Empty(context.PendingActions); + } + + [Fact] + public async Task WaitForExternalEventAsync_ShouldReturnDeserializedValue_WhenEventInHistory_IgnoringCase() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + EventRaised = new EventRaisedEvent { Name = "MyEvent", Input = "123" } + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var value = await context.WaitForExternalEventAsync("myevent"); + + Assert.Equal(123, value); + } + + [Fact] + public async Task WaitForExternalEventAsync_ShouldReturnUncompletedTask_WhenEventNotInHistory() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: [], + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var task = context.WaitForExternalEventAsync("missing-event"); + + Assert.False(task.IsCompleted); + + using var cts = new CancellationTokenSource(TimeSpan.FromMilliseconds(25)); + await Assert.ThrowsAnyAsync(() => + context.WaitForExternalEventAsync("missing-event", cts.Token)); + } + + [Fact] + public async Task WaitForExternalEventAsync_WithTimeoutOverload_ShouldCancel_WhenEventNotReceived() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: [], + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + await Assert.ThrowsAnyAsync(() => + context.WaitForExternalEventAsync("missing-event", TimeSpan.FromMilliseconds(25))); + } + + [Fact] + public void SetCustomStatus_ShouldUpdateCustomStatusProperty() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: [], + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + Assert.Null(context.CustomStatus); + + var status = new { Step = 2, Message = "working" }; + context.SetCustomStatus(status); + + Assert.Same(status, context.CustomStatus); + + context.SetCustomStatus(null); + + Assert.Null(context.CustomStatus); + } + + [Fact] + public void IsReplaying_ShouldBeTrue_WhenHistoryHasUnconsumedEvents_AndFalseAfterConsumption() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + TaskCompleted = new TaskCompletedEvent { Result = "\"ok\"" } + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + Assert.True(context.IsReplaying); + + _ = context.CallActivityAsync("Any"); + + Assert.False(context.IsReplaying); + } + + [Fact] + public async Task CreateTimer_ShouldReturnCompletedTask_WhenTimerFiredInHistory() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + TimerFired = new TimerFiredEvent() + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var task = context.CreateTimer(DateTime.UtcNow.AddMinutes(1), CancellationToken.None); + + await task; + + Assert.Empty(context.PendingActions); + } + + // [Fact] + // public async Task CreateTimer_ShouldScheduleAction_AndRemoveItOnCancellation() + // { + // var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + // + // var context = new WorkflowOrchestrationContext( + // name: "wf", + // instanceId: "i", + // pastEvents: [], + // newEvents: [], + // currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + // workflowSerializer: serializer, + // loggerFactory: NullLoggerFactory.Instance); + // + // using var cts = new CancellationTokenSource(); + // + // var timerTask = context.CreateTimer(DateTime.UtcNow.AddSeconds(3), cts.Token); + // + // Assert.Single(context.PendingActions); + // Assert.NotNull(context.PendingActions[0].CreateTimer); + // Assert.False(timerTask.IsCompleted); + // + // await cts.CancelAsync(); + // + // await Assert.ThrowsAnyAsync(() => timerTask); + // + // Assert.Empty(context.PendingActions); + // } + + [Fact] + public void SendEvent_ShouldAddSendEventAction_WithSerializedPayload() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: [], + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + context.SendEvent("child-1", "evt", new { A = 1 }); + + Assert.Single(context.PendingActions); + var action = context.PendingActions[0]; + + Assert.NotNull(action.SendEvent); + Assert.Equal("evt", action.SendEvent.Name); + Assert.Equal("child-1", action.SendEvent.Instance.InstanceId); + Assert.Contains("\"a\":1", action.SendEvent.Data); + } + + [Fact] + public void CreateReplaySafeLogger_ShouldReturnLoggerThatIsDisabledDuringReplay() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + TaskCompleted = new TaskCompletedEvent { Result = "\"ok\"" } + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: new AlwaysEnabledLoggerFactory()); + + Assert.True(context.IsReplaying); + + var logger = context.CreateReplaySafeLogger("cat"); + + Assert.False(logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Information)); + + _ = context.CallActivityAsync("Any"); // consumes 1 history event + + Assert.False(context.IsReplaying); + Assert.True(logger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Information)); + } + + [Fact] + public void ContinueAsNew_ShouldAddCompleteOrchestrationAction_WithCarryoverEvents_WhenPreserveUnprocessedEventsIsTrue() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent { EventRaised = new EventRaisedEvent { Name = "e1", Input = "\"x\"" } }, + new HistoryEvent { EventRaised = new EventRaisedEvent { Name = "e2", Input = "\"y\"" } } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + context.ContinueAsNew(newInput: new { V = 9 }, preserveUnprocessedEvents: true); + + Assert.Single(context.PendingActions); + var action = context.PendingActions[0]; + + Assert.NotNull(action.CompleteOrchestration); + Assert.Equal(OrchestrationStatus.ContinuedAsNew, action.CompleteOrchestration.OrchestrationStatus); + Assert.Contains("\"v\":9", action.CompleteOrchestration.Result); + Assert.Equal(2, action.CompleteOrchestration.CarryoverEvents.Count); + } + + [Fact] + public void ContinueAsNew_ShouldNotCarryOverEvents_WhenPreserveUnprocessedEventsIsFalse() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent { EventRaised = new EventRaisedEvent { Name = "e1", Input = "\"x\"" } }, + new HistoryEvent { EventRaised = new EventRaisedEvent { Name = "e2", Input = "\"y\"" } } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + context.ContinueAsNew(newInput: null, preserveUnprocessedEvents: false); + + Assert.Single(context.PendingActions); + var action = context.PendingActions[0]; + + Assert.NotNull(action.CompleteOrchestration); + Assert.Equal(OrchestrationStatus.ContinuedAsNew, action.CompleteOrchestration.OrchestrationStatus); + Assert.Empty(action.CompleteOrchestration.CarryoverEvents); + } + + [Fact] + public void NewGuid_ShouldBeDeterministic_ForSameInstanceIdAndTimestamp() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var now = new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc); + + var c1 = new WorkflowOrchestrationContext("wf", "00000000-0000-0000-0000-000000000001", + [], [], now, serializer, NullLoggerFactory.Instance); + + var c2 = new WorkflowOrchestrationContext("wf", "00000000-0000-0000-0000-000000000001", + [], [], now, serializer, NullLoggerFactory.Instance); + + var g1 = c1.NewGuid(); + var g2 = c2.NewGuid(); + + Assert.Equal(g1, g2); + } + + [Fact] + public async Task CallActivityAsync_ShouldThrowArgumentException_WhenNameIsNullOrWhitespace() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: [], + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + await Assert.ThrowsAsync(() => context.CallActivityAsync("")); + await Assert.ThrowsAsync(() => context.CallActivityAsync(" ")); + } + + [Fact] + public async Task CallActivityAsync_ShouldThrowInvalidOperationException_WhenHistoryEventIsUnexpectedType() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + TimerFired = new TimerFiredEvent() + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var ex = await Assert.ThrowsAsync(() => context.CallActivityAsync("Act")); + Assert.Contains("Unexpected history event type", ex.Message); + } + + [Fact] + public async Task WaitForExternalEventAsync_ShouldReturnDefault_WhenEventInHistoryHasNullInput() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + EventRaised = new EventRaisedEvent { Name = "MyEvent", Input = null } + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var value = await context.WaitForExternalEventAsync("MyEvent"); + + Assert.Equal(default, value); + } + + [Fact] + public async Task WaitForExternalEventAsync_WithTimeoutOverload_ShouldReturnResult_WhenEventIsInHistory() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + EventRaised = new EventRaisedEvent { Name = "MyEvent", Input = "456" } + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var value = await context.WaitForExternalEventAsync("MyEvent", TimeSpan.FromSeconds(10)); + + Assert.Equal(456, value); + } + + [Fact] + public void CallChildWorkflowAsync_ShouldScheduleSubOrchestration_WhenNotReplaying_AndUseProvidedInstanceId() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "parent", + pastEvents: [], + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var task = context.CallChildWorkflowAsync( + workflowName: "ChildWf", + input: new { V = 1 }, + options: new ChildWorkflowTaskOptions { InstanceId = "child-123" }); + + Assert.False(task.IsCompleted); + + Assert.Single(context.PendingActions); + var action = context.PendingActions[0]; + + Assert.NotNull(action.CreateSubOrchestration); + Assert.Equal("ChildWf", action.CreateSubOrchestration.Name); + Assert.Equal("child-123", action.CreateSubOrchestration.InstanceId); + Assert.Contains("\"v\":1", action.CreateSubOrchestration.Input); + } + + [Fact] + public async Task CallChildWorkflowAsync_ShouldReturnCompletedResult_FromHistorySubOrchestrationCompleted() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + SubOrchestrationInstanceCompleted = new SubOrchestrationInstanceCompletedEvent { Result = "42" } + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "parent", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var value = await context.CallChildWorkflowAsync("ChildWf"); + + Assert.Equal(42, value); + Assert.Empty(context.PendingActions); + } + + [Fact] + public async Task CallChildWorkflowAsync_ShouldThrowWorkflowTaskFailedException_FromHistorySubOrchestrationFailed() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + SubOrchestrationInstanceFailed = new SubOrchestrationInstanceFailedEvent + { + FailureDetails = new TaskFailureDetails + { + ErrorType = "ChildError", + ErrorMessage = "boom", + StackTrace = "trace" + } + } + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "parent", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var ex = await Assert.ThrowsAsync(() => context.CallChildWorkflowAsync("ChildWf")); + Assert.Contains("Child workflow 'ChildWf' failed", ex.Message); + Assert.Equal("ChildError", ex.FailureDetails.ErrorType); + Assert.Equal("boom", ex.FailureDetails.ErrorMessage); + Assert.Equal("trace", ex.FailureDetails.StackTrace); + } + + [Fact] + public async Task CallChildWorkflowAsync_ShouldThrowJsonDeserializationException_WhenHistoryEventIsUnexpectedType() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + TaskCompleted = new TaskCompletedEvent { Result = "\"not-child\"" } + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "parent", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: NullLoggerFactory.Instance); + + var ex = await Assert.ThrowsAsync(() => context.CallChildWorkflowAsync("ChildWf")); + Assert.Contains("The JSON value could not be converted to ", ex.Message); + } + + [Fact] + public void CreateReplaySafeLogger_TypeAndGenericOverloads_ShouldBehaveLikeCategoryOverload() + { + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + + var history = new[] + { + new HistoryEvent + { + TaskCompleted = new TaskCompletedEvent { Result = "\"ok\"" } + } + }; + + var context = new WorkflowOrchestrationContext( + name: "wf", + instanceId: "i", + pastEvents: history, + newEvents: [], + currentUtcDateTime: new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc), + workflowSerializer: serializer, + loggerFactory: new AlwaysEnabledLoggerFactory()); + + Assert.True(context.IsReplaying); + + var typeLogger = context.CreateReplaySafeLogger(typeof(WorkflowOrchestrationContextTests)); + var genericLogger = context.CreateReplaySafeLogger(); + + Assert.False(typeLogger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Information)); + Assert.False(genericLogger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Information)); + + _ = context.CallActivityAsync("Any"); // consumes 1 history event + + Assert.False(context.IsReplaying); + Assert.True(typeLogger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Information)); + Assert.True(genericLogger.IsEnabled(Microsoft.Extensions.Logging.LogLevel.Information)); + } + + private sealed class AlwaysEnabledLoggerFactory : Microsoft.Extensions.Logging.ILoggerFactory + { + public void AddProvider(Microsoft.Extensions.Logging.ILoggerProvider provider) { } + public Microsoft.Extensions.Logging.ILogger CreateLogger(string categoryName) => new AlwaysEnabledLogger(); + public void Dispose() { } + } + + private sealed class AlwaysEnabledLogger : Microsoft.Extensions.Logging.ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull => null; + public bool IsEnabled(Microsoft.Extensions.Logging.LogLevel logLevel) => true; + public void Log( + Microsoft.Extensions.Logging.LogLevel logLevel, + Microsoft.Extensions.Logging.EventId eventId, + TState state, + Exception? exception, + Func formatter) + { + } + } +} diff --git a/test/Dapr.Workflow.Test/Worker/WorkflowWorkerTests.cs b/test/Dapr.Workflow.Test/Worker/WorkflowWorkerTests.cs new file mode 100644 index 000000000..96d5b12bf --- /dev/null +++ b/test/Dapr.Workflow.Test/Worker/WorkflowWorkerTests.cs @@ -0,0 +1,705 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using System.Reflection; +using System.Text.Json; +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Abstractions; +using Dapr.Workflow.Serialization; +using Dapr.Workflow.Worker; +using Dapr.Workflow.Worker.Grpc; +using Grpc.Core; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Type = System.Type; + +namespace Dapr.Workflow.Test.Worker; + +public class WorkflowWorkerTests +{ + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenGrpcClientIsNull() + { + Assert.Throws(() => + new WorkflowWorker(null!, Mock.Of(), Mock.Of(), Mock.Of(), + new ServiceCollection().BuildServiceProvider(), new WorkflowRuntimeOptions())); + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenWorkflowsFactoryIsNull() + { + var grpcClient = CreateGrpcClientMock().Object; + + Assert.Throws(() => + new WorkflowWorker(grpcClient, null!, Mock.Of(), Mock.Of(), + new ServiceCollection().BuildServiceProvider(), new WorkflowRuntimeOptions())); + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenLoggerFactoryIsNull() + { + var grpcClient = CreateGrpcClientMock().Object; + + Assert.Throws(() => + new WorkflowWorker(grpcClient, Mock.Of(), null!, Mock.Of(), + new ServiceCollection().BuildServiceProvider(), new WorkflowRuntimeOptions())); + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenSerializerIsNull() + { + var grpcClient = CreateGrpcClientMock().Object; + + Assert.Throws(() => + new WorkflowWorker(grpcClient, Mock.Of(), Mock.Of(), null!, + new ServiceCollection().BuildServiceProvider(), new WorkflowRuntimeOptions())); + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenServiceProviderIsNull() + { + var grpcClient = CreateGrpcClientMock().Object; + + Assert.Throws(() => + new WorkflowWorker(grpcClient, Mock.Of(), Mock.Of(), Mock.Of(), + null!, new WorkflowRuntimeOptions())); + } + + [Fact] + public void Constructor_ShouldThrowArgumentNullException_WhenOptionsIsNull() + { + var grpcClient = CreateGrpcClientMock().Object; + + Assert.Throws(() => + new WorkflowWorker(grpcClient, Mock.Of(), Mock.Of(), Mock.Of(), + new ServiceCollection().BuildServiceProvider(), null!)); + } + + [Fact] + public async Task StopAsync_ShouldNotThrow_WhenProtocolHandlerWasNeverCreated() + { + var grpcClient = CreateGrpcClientMock().Object; + var worker = new WorkflowWorker( + grpcClient, + Mock.Of(), + NullLoggerFactory.Instance, + Mock.Of(), + new ServiceCollection().BuildServiceProvider(), + new WorkflowRuntimeOptions()); + + await worker.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task StopAsync_ShouldDisposeProtocolHandler_WhenPresent() + { + var grpcClient = CreateGrpcClientMock().Object; + var worker = new WorkflowWorker( + grpcClient, + Mock.Of(), + NullLoggerFactory.Instance, + Mock.Of(), + new ServiceCollection().BuildServiceProvider(), + new WorkflowRuntimeOptions()); + + var protocolHandler = new GrpcProtocolHandler(CreateGrpcClientMock().Object, NullLoggerFactory.Instance, 1, 1); + + var field = typeof(WorkflowWorker).GetField("_protocolHandler", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(field); + field.SetValue(worker, protocolHandler); + + await worker.StopAsync(CancellationToken.None); + } + + [Fact] + public async Task ExecuteAsync_ShouldComplete_WhenGrpcStreamCompletesImmediately() + { + var services = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var factory = new StubWorkflowsFactory(); + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns(CreateServerStreamingCall(EmptyWorkItems())); + + var worker = new WorkflowWorker( + grpcClientMock.Object, + factory, + NullLoggerFactory.Instance, + serializer, + services, + options); + + await InvokeExecuteAsync(worker, CancellationToken.None); + } + + [Fact] + public async Task ExecuteAsync_ShouldSwallowOperationCanceledException_WhenStoppingTokenIsCanceled() + { + var services = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var factory = new StubWorkflowsFactory(); + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Returns((GetWorkItemsRequest _, Metadata? __, DateTime? ___, CancellationToken ct) => + { + ct.ThrowIfCancellationRequested(); + return CreateServerStreamingCall(EmptyWorkItems()); + }); + + var worker = new WorkflowWorker( + grpcClientMock.Object, + factory, + NullLoggerFactory.Instance, + serializer, + services, + options); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + await InvokeExecuteAsync(worker, cts.Token); + } + + [Fact] + public async Task HandleOrchestratorResponseAsync_ShouldReturnEmptyActions_WhenWorkflowNameMissingInHistory() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var worker = new WorkflowWorker( + CreateGrpcClientMock().Object, + new StubWorkflowsFactory(), + NullLoggerFactory.Instance, + serializer, + sp, + options); + + var request = new OrchestratorRequest + { + InstanceId = "i", + PastEvents = { new HistoryEvent { TimerFired = new TimerFiredEvent() } } + }; + + var response = await InvokeHandleOrchestratorResponseAsync(worker, request); + + Assert.Equal("i", response.InstanceId); + Assert.Empty(response.Actions); + Assert.Null(response.CustomStatus); + } + + [Fact] + public async Task HandleOrchestratorResponseAsync_ShouldReturnEmptyActions_WhenWorkflowNotInRegistry() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var worker = new WorkflowWorker( + CreateGrpcClientMock().Object, + new StubWorkflowsFactory(), // no registrations + NullLoggerFactory.Instance, + serializer, + sp, + options); + + var request = new OrchestratorRequest + { + InstanceId = "i", + PastEvents = + { + new HistoryEvent + { + ExecutionStarted = new ExecutionStartedEvent { Name = "wf", Input = "123" } + } + } + }; + + var response = await InvokeHandleOrchestratorResponseAsync(worker, request); + + Assert.Equal("i", response.InstanceId); + Assert.Empty(response.Actions); + Assert.Null(response.CustomStatus); + } + + [Fact] + public async Task HandleOrchestratorResponseAsync_ShouldCompleteWorkflow_AndIncludeOutputAndCustomStatus() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var factory = new StubWorkflowsFactory(); + factory.AddWorkflow("wf", new InlineWorkflow( + inputType: typeof(int), + run: async (ctx, input) => + { + ctx.SetCustomStatus(new { Step = 7 }); + await Task.Yield(); + return (int)input! + 1; + })); + + var worker = new WorkflowWorker( + CreateGrpcClientMock().Object, + factory, + NullLoggerFactory.Instance, + serializer, + sp, + options); + + var request = new OrchestratorRequest + { + InstanceId = "i", + PastEvents = + { + new HistoryEvent + { + ExecutionStarted = new ExecutionStartedEvent { Name = "wf", Input = "41" } + } + } + }; + + var response = await InvokeHandleOrchestratorResponseAsync(worker, request); + + Assert.Equal("i", response.InstanceId); + Assert.NotNull(response.CustomStatus); + Assert.Contains("\"step\":7", response.CustomStatus); + + Assert.NotEmpty(response.Actions); + + var completion = response.Actions.Single(a => a.CompleteOrchestration != null).CompleteOrchestration!; + Assert.Equal(OrchestrationStatus.Completed, completion.OrchestrationStatus); + Assert.Equal("42", completion.Result); + } + + [Fact] + public async Task HandleOrchestratorResponseAsync_ShouldNotAddCompletedAction_WhenWorkflowContinuesAsNew() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var factory = new StubWorkflowsFactory(); + factory.AddWorkflow("wf", new InlineWorkflow( + inputType: typeof(string), + run: (ctx, input) => + { + ctx.ContinueAsNew(new { Next = "x" }, preserveUnprocessedEvents: true); + return Task.FromResult(null); + })); + + var worker = new WorkflowWorker( + CreateGrpcClientMock().Object, + factory, + NullLoggerFactory.Instance, + serializer, + sp, + options); + + var request = new OrchestratorRequest + { + InstanceId = "i", + PastEvents = + { + new HistoryEvent + { + ExecutionStarted = new ExecutionStartedEvent { Name = "wf", Input = "\"in\"" } + } + } + }; + + var response = await InvokeHandleOrchestratorResponseAsync(worker, request); + + Assert.Equal("i", response.InstanceId); + + var completeActions = response.Actions.Where(a => a.CompleteOrchestration != null).ToList(); + Assert.Single(completeActions); + Assert.Equal(OrchestrationStatus.ContinuedAsNew, completeActions[0].CompleteOrchestration!.OrchestrationStatus); + + Assert.DoesNotContain(response.Actions, + a => a.CompleteOrchestration?.OrchestrationStatus == OrchestrationStatus.Completed); + } + + [Fact] + public async Task HandleOrchestratorResponseAsync_ShouldReturnFailedCompletion_WhenWorkflowThrows() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var factory = new StubWorkflowsFactory(); + factory.AddWorkflow("wf", new InlineWorkflow( + inputType: typeof(int), + run: (_, _) => throw new InvalidOperationException("boom"))); + + var worker = new WorkflowWorker( + CreateGrpcClientMock().Object, + factory, + NullLoggerFactory.Instance, + serializer, + sp, + options); + + var request = new OrchestratorRequest + { + InstanceId = "i", + PastEvents = + { + new HistoryEvent + { + ExecutionStarted = new ExecutionStartedEvent { Name = "wf", Input = "1" } + } + } + }; + + var response = await InvokeHandleOrchestratorResponseAsync(worker, request); + + Assert.Equal("i", response.InstanceId); + + var complete = Assert.Single(response.Actions).CompleteOrchestration; + Assert.NotNull(complete); + Assert.Equal(OrchestrationStatus.Failed, complete!.OrchestrationStatus); + Assert.NotNull(complete.FailureDetails); + Assert.Contains("boom", complete.FailureDetails.ErrorMessage); + } + + [Fact] + public async Task HandleActivityResponseAsync_ShouldReturnNotFoundFailure_WhenActivityNotInRegistry() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var worker = new WorkflowWorker( + CreateGrpcClientMock().Object, + new StubWorkflowsFactory(), + NullLoggerFactory.Instance, + serializer, + sp, + options); + + var request = new ActivityRequest + { + Name = "act", + TaskId = 7, + OrchestrationInstance = new OrchestrationInstance { InstanceId = "i" }, + Input = "1" + }; + + var response = await InvokeHandleActivityResponseAsync(worker, request); + + Assert.Equal("i", response.InstanceId); + Assert.Equal(7, response.TaskId); + Assert.NotNull(response.FailureDetails); + Assert.Equal("ActivityNotFoundException", response.FailureDetails.ErrorType); + Assert.Contains("Activity 'act' not found", response.FailureDetails.ErrorMessage); + } + + [Fact] + public async Task HandleActivityResponseAsync_ShouldExecuteActivity_AndSerializeResult() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var factory = new StubWorkflowsFactory(); + factory.AddActivity("act", new InlineActivity( + inputType: typeof(int), + run: async (_, input) => + { + await Task.Yield(); + return (int)input! * 2; + })); + + var worker = new WorkflowWorker( + CreateGrpcClientMock().Object, + factory, + NullLoggerFactory.Instance, + serializer, + sp, + options); + + var request = new ActivityRequest + { + Name = "act", + TaskId = 7, + OrchestrationInstance = new OrchestrationInstance { InstanceId = "i" }, + Input = "21" + }; + + var response = await InvokeHandleActivityResponseAsync(worker, request); + + Assert.Equal("i", response.InstanceId); + Assert.Equal(7, response.TaskId); + Assert.Null(response.FailureDetails); + Assert.Equal("42", response.Result); + } + + [Fact] + public async Task HandleActivityResponseAsync_ShouldReturnFailureDetails_WhenActivityThrows() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var factory = new StubWorkflowsFactory(); + factory.AddActivity("act", new InlineActivity( + inputType: typeof(int), + run: (_, _) => throw new InvalidOperationException("boom"))); + + var worker = new WorkflowWorker( + CreateGrpcClientMock().Object, + factory, + NullLoggerFactory.Instance, + serializer, + sp, + options); + + var request = new ActivityRequest + { + Name = "act", + TaskId = 7, + OrchestrationInstance = new OrchestrationInstance { InstanceId = "i" }, + Input = "1" + }; + + var response = await InvokeHandleActivityResponseAsync(worker, request); + + Assert.Equal("i", response.InstanceId); + Assert.Equal(7, response.TaskId); + Assert.NotNull(response.FailureDetails); + Assert.Contains("boom", response.FailureDetails.ErrorMessage); + } + + [Fact] + public async Task ExecuteAsync_ShouldRethrow_WhenGrpcProtocolHandlerStartFailsWithException() + { + var services = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var factory = new StubWorkflowsFactory(); + + var grpcClientMock = CreateGrpcClientMock(); + grpcClientMock + .Setup(x => x.GetWorkItems(It.IsAny(), null, null, It.IsAny())) + .Throws(new InvalidOperationException("boom")); + + var worker = new WorkflowWorker( + grpcClientMock.Object, + factory, + NullLoggerFactory.Instance, + serializer, + services, + options); + + var ex = await Assert.ThrowsAsync(() => InvokeExecuteAsync(worker, CancellationToken.None)); + Assert.Contains("boom", ex.Message); + } + + [Fact] + public async Task HandleOrchestratorResponseAsync_ShouldUseFirstEventTimestamp_WhenPresent_AndSerializeEmptyResult_WhenOutputIsNull() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var factory = new StubWorkflowsFactory(); + factory.AddWorkflow("wf", new InlineWorkflow( + inputType: typeof(int), + run: (ctx, input) => + { + // Exercise the timestamp-based CurrentUtcDateTime path via deterministic GUID generation + // (no assertion on GUID value needed; just cover the code path safely). + _ = ctx.NewGuid(); + + // Return null output -> worker should serialize to empty string in completion action. + return Task.FromResult(null); + })); + + var worker = new WorkflowWorker( + CreateGrpcClientMock().Object, + factory, + NullLoggerFactory.Instance, + serializer, + sp, + options); + + var request = new OrchestratorRequest + { + InstanceId = "i", + PastEvents = + { + new HistoryEvent + { + Timestamp = Google.Protobuf.WellKnownTypes.Timestamp.FromDateTime( + new DateTime(2025, 01, 01, 12, 0, 0, DateTimeKind.Utc)), + ExecutionStarted = new ExecutionStartedEvent + { + Name = "wf", + Input = "123" + } + } + } + }; + + var response = await InvokeHandleOrchestratorResponseAsync(worker, request); + + Assert.Equal("i", response.InstanceId); + Assert.Null(response.CustomStatus); + + var complete = response.Actions.Single(a => a.CompleteOrchestration != null).CompleteOrchestration!; + Assert.Equal(OrchestrationStatus.Completed, complete.OrchestrationStatus); + Assert.Equal(string.Empty, complete.Result); + } + + [Fact] + public async Task HandleActivityResponseAsync_ShouldUseEmptyInstanceId_WhenOrchestrationInstanceIsNull_AndReturnEmptyResult_WhenOutputIsNull() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var serializer = new JsonWorkflowSerializer(new JsonSerializerOptions(JsonSerializerDefaults.Web)); + var options = new WorkflowRuntimeOptions(); + + var factory = new StubWorkflowsFactory(); + factory.AddActivity("act", new InlineActivity( + inputType: typeof(int), + run: (_, __) => Task.FromResult(null))); // null output -> empty string result + + var worker = new WorkflowWorker( + CreateGrpcClientMock().Object, + factory, + NullLoggerFactory.Instance, + serializer, + sp, + options); + + var request = new ActivityRequest + { + Name = "act", + TaskId = 9, + OrchestrationInstance = null, + Input = "" // empty input -> no deserialization branch + }; + + var response = await InvokeHandleActivityResponseAsync(worker, request); + + Assert.Equal(string.Empty, response.InstanceId); + Assert.Equal(9, response.TaskId); + Assert.Null(response.FailureDetails); + Assert.Equal(string.Empty, response.Result); + } + + private static async Task InvokeExecuteAsync(WorkflowWorker worker, CancellationToken token) + { + var method = typeof(WorkflowWorker).GetMethod("ExecuteAsync", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var task = (Task)method!.Invoke(worker, [token])!; + await task; + } + + private static async Task InvokeHandleOrchestratorResponseAsync(WorkflowWorker worker, OrchestratorRequest request) + { + var method = typeof(WorkflowWorker).GetMethod("HandleOrchestratorResponseAsync", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var task = (Task)method!.Invoke(worker, [request])!; + return await task; + } + + private static async Task InvokeHandleActivityResponseAsync(WorkflowWorker worker, ActivityRequest request) + { + var method = typeof(WorkflowWorker).GetMethod("HandleActivityResponseAsync", BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var task = (Task)method!.Invoke(worker, [request])!; + return await task; + } + + private static Mock CreateGrpcClientMock() + { + var callInvoker = new Mock(MockBehavior.Loose); + return new Mock(callInvoker.Object); + } + + private static AsyncServerStreamingCall CreateServerStreamingCall(IAsyncEnumerable items) + { + var stream = new TestAsyncStreamReader(items); + + return new AsyncServerStreamingCall( + stream, + Task.FromResult(new Metadata()), + () => Status.DefaultSuccess, + () => [], + () => { }); + } + + private sealed class TestAsyncStreamReader(IAsyncEnumerable items) : IAsyncStreamReader + { + private readonly IAsyncEnumerator _enumerator = items.GetAsyncEnumerator(); + public WorkItem Current => _enumerator.Current; + public Task MoveNext(CancellationToken cancellationToken) => _enumerator.MoveNextAsync().AsTask(); + } + + private sealed class StubWorkflowsFactory : IWorkflowsFactory + { + private readonly Dictionary _workflows = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _activities = new(StringComparer.OrdinalIgnoreCase); + + public void AddWorkflow(string name, IWorkflow wf) => _workflows[name] = wf; + public void AddActivity(string name, IWorkflowActivity act) => _activities[name] = act; + + public void RegisterWorkflow(string? name = null) where TWorkflow : class, IWorkflow => throw new NotSupportedException(); + public void RegisterWorkflow(string name, Func> implementation) => throw new NotSupportedException(); + public void RegisterActivity(string? name = null) where TActivity : class, IWorkflowActivity => throw new NotSupportedException(); + public void RegisterActivity(string name, Func> implementation) => throw new NotSupportedException(); + + public bool TryCreateWorkflow(TaskIdentifier identifier, IServiceProvider serviceProvider, out IWorkflow? workflow) + => _workflows.TryGetValue(identifier.Name, out workflow); + + public bool TryCreateActivity(TaskIdentifier identifier, IServiceProvider serviceProvider, out IWorkflowActivity? activity) + => _activities.TryGetValue(identifier.Name, out activity); + } + + private sealed class InlineWorkflow(Type inputType, Func> run) : IWorkflow + { + public Type InputType { get; } = inputType; + public Type OutputType => typeof(object); + + public Task RunAsync(WorkflowContext context, object? input) => run(context, input); + } + + private sealed class InlineActivity(Type inputType, Func> run) : IWorkflowActivity + { + public Type InputType { get; } = inputType; + public Type OutputType => typeof(object); + + public Task RunAsync(WorkflowActivityContext context, object? input) => run(context, input); + } + + private static async IAsyncEnumerable EmptyWorkItems() + { + await Task.CompletedTask; + yield break; + } +} diff --git a/test/Dapr.Workflow.Test/Worker/WorkflowsFactoryTests.cs b/test/Dapr.Workflow.Test/Worker/WorkflowsFactoryTests.cs new file mode 100644 index 000000000..b6f63a0a7 --- /dev/null +++ b/test/Dapr.Workflow.Test/Worker/WorkflowsFactoryTests.cs @@ -0,0 +1,422 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Workflow.Abstractions; +using Dapr.Workflow.Worker; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; + +namespace Dapr.Workflow.Test.Worker; + +public class WorkflowsFactoryTests +{ + [Fact] + public void RegisterWorkflow_Generic_ShouldDefaultNameToTypeName_AndCreateViaDI() + { + var services = new ServiceCollection(); + services.AddSingleton(new Dependency("dep-1")); + var sp = services.BuildServiceProvider(); + + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + factory.RegisterWorkflow(); + + var created = factory.TryCreateWorkflow(new TaskIdentifier(nameof(TestWorkflowWithDependency)), sp, out var workflow); + + Assert.True(created); + Assert.NotNull(workflow); + Assert.IsType(workflow); + Assert.Equal("dep-1", ((TestWorkflowWithDependency)workflow!).Dep.Value); + } + + [Fact] + public void RegisterActivity_Generic_ShouldDefaultNameToTypeName_AndCreateViaDI() + { + var services = new ServiceCollection(); + services.AddSingleton(new Dependency("dep-2")); + var sp = services.BuildServiceProvider(); + + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + factory.RegisterActivity(); + + var created = factory.TryCreateActivity(new TaskIdentifier(nameof(TestActivityWithDependency)), sp, out var activity); + + Assert.True(created); + Assert.NotNull(activity); + Assert.IsType(activity); + Assert.Equal("dep-2", ((TestActivityWithDependency)activity!).Dep.Value); + } + + [Fact] + public void RegisterActivity_Generic_ShouldUseProvidedName_WhenNameIsSpecified() + { + var factory = new WorkflowsFactory(NullLogger.Instance); + + factory.RegisterActivity("custom-activity"); + + var sp = new ServiceCollection().BuildServiceProvider(); + + Assert.True(factory.TryCreateActivity(new TaskIdentifier("custom-activity"), sp, out var activity)); + Assert.NotNull(activity); + Assert.IsType(activity); + + Assert.False(factory.TryCreateActivity(new TaskIdentifier(nameof(TestActivityA)), sp, out _)); + } + + [Fact] + public void RegisterWorkflow_Generic_ShouldBeCaseInsensitive_ForLookup() + { + var factory = new WorkflowsFactory(NullLogger.Instance); + + factory.RegisterWorkflow("MyWorkflow"); + + var sp = new ServiceCollection().BuildServiceProvider(); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier("myworkflow"), sp, out var workflow)); + Assert.NotNull(workflow); + Assert.IsType(workflow); + } + + [Fact] + public void RegisterWorkflow_Generic_ShouldUseProvidedName_WhenNameIsSpecified() + { + var factory = new WorkflowsFactory(NullLogger.Instance); + + factory.RegisterWorkflow("custom-workflow"); + + var sp = new ServiceCollection().BuildServiceProvider(); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier("custom-workflow"), sp, out var workflow)); + Assert.NotNull(workflow); + Assert.IsType(workflow); + + Assert.False(factory.TryCreateWorkflow(new TaskIdentifier(nameof(TestWorkflowA)), sp, out _)); + } + + [Fact] + public void RegisterWorkflow_Function_ShouldThrowArgumentException_WhenNameIsNullOrWhitespace() + { + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + Assert.Throws(() => factory.RegisterWorkflow("", (_, x) => Task.FromResult(x))); + Assert.Throws(() => factory.RegisterWorkflow(" ", (_, x) => Task.FromResult(x))); + } + + [Fact] + public void RegisterWorkflow_Generic_ShouldNotOverwrite_WhenRegisteringSameNameTwice() + { + var factory = new WorkflowsFactory(NullLogger.Instance); + + factory.RegisterWorkflow("wf"); + factory.RegisterWorkflow("wf"); // should be ignored (TryAdd) + + var sp = new ServiceCollection().BuildServiceProvider(); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier("wf"), sp, out var workflow)); + Assert.NotNull(workflow); + Assert.IsType(workflow); + } + + [Fact] + public void RegisterActivity_Generic_ShouldNotOverwrite_WhenRegisteringSameNameTwice() + { + var factory = new WorkflowsFactory(NullLogger.Instance); + + factory.RegisterActivity("act"); + factory.RegisterActivity("act"); // should be ignored (TryAdd) + + var sp = new ServiceCollection().BuildServiceProvider(); + + Assert.True(factory.TryCreateActivity(new TaskIdentifier("act"), sp, out var activity)); + Assert.NotNull(activity); + Assert.IsType(activity); + } + + [Fact] + public void RegisterWorkflow_Function_ShouldThrowArgumentNullException_WhenImplementationIsNull() + { + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + Assert.Throws(() => factory.RegisterWorkflow("wf", null!)); + } + + [Fact] + public void RegisterActivity_Generic_ShouldBeCaseInsensitive_ForLookup() + { + var factory = new WorkflowsFactory(NullLogger.Instance); + + factory.RegisterActivity("MyActivity"); + + var sp = new ServiceCollection().BuildServiceProvider(); + + Assert.True(factory.TryCreateActivity(new TaskIdentifier("myactivity"), sp, out var activity)); + Assert.NotNull(activity); + Assert.IsType(activity); + } + + [Fact] + public async Task RegisterWorkflow_Function_ShouldNotOverwrite_WhenRegisteringSameNameTwice() + { + var factory = new WorkflowsFactory(NullLogger.Instance); + + factory.RegisterWorkflow("wf", (_, x) => Task.FromResult(x + 1)); + factory.RegisterWorkflow("wf", (_, x) => Task.FromResult(x + 999)); // should be ignored (TryAdd) + + var sp = new ServiceCollection().BuildServiceProvider(); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier("wf"), sp, out var workflow)); + Assert.NotNull(workflow); + + var result = await workflow!.RunAsync(new FakeWorkflowContext(), 10); + + Assert.Equal(11, result); + } + + [Fact] + public async Task RegisterActivity_Function_ShouldNotOverwrite_WhenRegisteringSameNameTwice() + { + var factory = new WorkflowsFactory(NullLogger.Instance); + + factory.RegisterActivity("act", (_, x) => Task.FromResult(x + 1)); + factory.RegisterActivity("act", (_, x) => Task.FromResult(x + 999)); // should be ignored (TryAdd) + + var sp = new ServiceCollection().BuildServiceProvider(); + + Assert.True(factory.TryCreateActivity(new TaskIdentifier("act"), sp, out var activity)); + Assert.NotNull(activity); + + var result = await activity!.RunAsync(new FakeActivityContext(), 10); + + Assert.Equal(11, result); + } + + [Fact] + public void RegisterActivity_Function_ShouldThrowArgumentException_WhenNameIsNullOrWhitespace() + { + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + Assert.Throws(() => factory.RegisterActivity("", (_, x) => Task.FromResult(x))); + Assert.Throws(() => factory.RegisterActivity(" ", (_, x) => Task.FromResult(x))); + } + + [Fact] + public void RegisterActivity_Function_ShouldThrowArgumentNullException_WhenImplementationIsNull() + { + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + Assert.Throws(() => factory.RegisterActivity("act", null!)); + } + + [Fact] + public void TryCreateWorkflow_ShouldReturnFalse_WhenNotRegistered() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + var created = factory.TryCreateWorkflow(new TaskIdentifier("missing"), sp, out var workflow); + + Assert.False(created); + Assert.Null(workflow); + } + + [Fact] + public void TryCreateActivity_ShouldReturnFalse_WhenNotRegistered() + { + var sp = new ServiceCollection().BuildServiceProvider(); + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + var created = factory.TryCreateActivity(new TaskIdentifier("missing"), sp, out var activity); + + Assert.False(created); + Assert.Null(activity); + } + + [Fact] + public void TryCreateWorkflow_ShouldReturnFalse_WhenFactoryThrows() + { + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + factory.RegisterWorkflow(); + + var created = factory.TryCreateWorkflow(new TaskIdentifier(nameof(ThrowingWorkflow)), sp, out var workflow); + + Assert.False(created); + Assert.Null(workflow); + } + + [Fact] + public void TryCreateActivity_ShouldReturnFalse_WhenFactoryThrows() + { + var services = new ServiceCollection(); + var sp = services.BuildServiceProvider(); + + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + factory.RegisterActivity(); + + var created = factory.TryCreateActivity(new TaskIdentifier(nameof(ThrowingActivity)), sp, out var activity); + + Assert.False(created); + Assert.Null(activity); + } + + [Fact] + public async Task RegisteredFunctionWorkflow_ShouldInvokeImplementation_AndUseTypedInput() + { + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + factory.RegisterWorkflow("wf-fn", (_, x) => Task.FromResult($"v:{x}")); + + var sp = new ServiceCollection().BuildServiceProvider(); + var created = factory.TryCreateWorkflow(new TaskIdentifier("wf-fn"), sp, out var workflow); + + Assert.True(created); + Assert.NotNull(workflow); + Assert.Equal(typeof(int), workflow!.InputType); + Assert.Equal(typeof(string), workflow.OutputType); + + var result = await workflow.RunAsync(new FakeWorkflowContext(), 7); + + Assert.Equal("v:7", result); + } + + [Fact] + public async Task RegisteredFunctionActivity_ShouldInvokeImplementation_AndUseTypedInput() + { + var logger = Mock.Of>(); + var factory = new WorkflowsFactory(logger); + + factory.RegisterActivity("act-fn", (_, x) => Task.FromResult($"v:{x}")); + + var sp = new ServiceCollection().BuildServiceProvider(); + var created = factory.TryCreateActivity(new TaskIdentifier("act-fn"), sp, out var activity); + + Assert.True(created); + Assert.NotNull(activity); + Assert.Equal(typeof(int), activity!.InputType); + Assert.Equal(typeof(string), activity.OutputType); + + var result = await activity.RunAsync(new FakeActivityContext(), 7); + + Assert.Equal("v:7", result); + } + + private sealed record Dependency(string Value); + + private sealed class TestWorkflowWithDependency(Dependency dep) : IWorkflow + { + public Dependency Dep { get; } = dep; + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + + public Task RunAsync(WorkflowContext context, object? input) => Task.FromResult(null); + } + + private sealed class TestActivityWithDependency(Dependency dep) : IWorkflowActivity + { + public Dependency Dep { get; } = dep; + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + + public Task RunAsync(WorkflowActivityContext context, object? input) => Task.FromResult(null); + } + + private sealed class ThrowingWorkflow : IWorkflow + { + public ThrowingWorkflow() => throw new InvalidOperationException("boom"); + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + public Task RunAsync(WorkflowContext context, object? input) => Task.FromResult(null); + } + + private sealed class ThrowingActivity : IWorkflowActivity + { + public ThrowingActivity() => throw new InvalidOperationException("boom"); + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + public Task RunAsync(WorkflowActivityContext context, object? input) => Task.FromResult(null); + } + + private sealed class TestWorkflowA : IWorkflow + { + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + public Task RunAsync(WorkflowContext context, object? input) => Task.FromResult(null); + } + + private sealed class TestWorkflowB : IWorkflow + { + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + public Task RunAsync(WorkflowContext context, object? input) => Task.FromResult(null); + } + + private sealed class TestActivityA : IWorkflowActivity + { + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + public Task RunAsync(WorkflowActivityContext context, object? input) => Task.FromResult(null); + } + + private sealed class TestActivityB : IWorkflowActivity + { + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + public Task RunAsync(WorkflowActivityContext context, object? input) => Task.FromResult(null); + } + + private sealed class FakeWorkflowContext : WorkflowContext + { + public override string Name => "wf"; + public override string InstanceId => "i"; + public override DateTime CurrentUtcDateTime => DateTime.UtcNow; + public override bool IsReplaying => false; + + public override Task CallActivityAsync(string name, object? input = null, WorkflowTaskOptions? options = null) => throw new NotSupportedException(); + public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken) => throw new NotSupportedException(); + public override Task WaitForExternalEventAsync(string eventName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public override Task WaitForExternalEventAsync(string eventName, TimeSpan timeout) => throw new NotSupportedException(); + public override void SendEvent(string instanceId, string eventName, object payload) => throw new NotSupportedException(); + public override void SetCustomStatus(object? customStatus) => throw new NotSupportedException(); + public override Task CallChildWorkflowAsync(string workflowName, object? input = null, ChildWorkflowTaskOptions? options = null) => throw new NotSupportedException(); + public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) => throw new NotSupportedException(); + public override Guid NewGuid() => Guid.NewGuid(); + public override ILogger CreateReplaySafeLogger(string categoryName) => throw new NotSupportedException(); + public override ILogger CreateReplaySafeLogger(Type type) => throw new NotSupportedException(); + public override ILogger CreateReplaySafeLogger() => throw new NotSupportedException(); + } + + private sealed class FakeActivityContext : WorkflowActivityContext + { + public override TaskIdentifier Identifier => new("act"); + public override string TaskExecutionKey => "test-key"; + public override string InstanceId => "i"; + } +} diff --git a/test/Dapr.Workflow.Test/WorkflowActivityTest.cs b/test/Dapr.Workflow.Test/WorkflowActivityTest.cs index 895a746d0..7aecdcb23 100644 --- a/test/Dapr.Workflow.Test/WorkflowActivityTest.cs +++ b/test/Dapr.Workflow.Test/WorkflowActivityTest.cs @@ -1,54 +1,54 @@ -// ------------------------------------------------------------------------ -// Copyright 2021 The Dapr Authors -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// http://www.apache.org/licenses/LICENSE-2.0 -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// ------------------------------------------------------------------------ - -namespace Dapr.Workflow.Test; - -using Moq; -using System.Threading.Tasks; -using Xunit; -using Xunit.Sdk; - -/// -/// Contains tests for WorkflowActivityContext. -/// -public class WorkflowActivityTest -{ - private IWorkflowActivity workflowActivity; - - private Mock workflowActivityContextMock; - - public WorkflowActivityTest() - { - this.workflowActivity = new TestDaprWorkflowActivity(); - this.workflowActivityContextMock = new Mock(); - } - - [Fact] - public async Task RunAsync_ShouldReturnCorrectContextInstanceId() - { - this.workflowActivityContextMock.Setup((x) => x.InstanceId).Returns("instanceId"); - - string result = (string) (await this.workflowActivity.RunAsync(this.workflowActivityContextMock.Object, "input"))!; - - Assert.Equal("instanceId", result); - } - - - public class TestDaprWorkflowActivity : WorkflowActivity - { - public override Task RunAsync(WorkflowActivityContext context, string input) - { - return Task.FromResult(context.InstanceId); - } - } -} \ No newline at end of file +// // ------------------------------------------------------------------------ +// // Copyright 2021 The Dapr Authors +// // Licensed under the Apache License, Version 2.0 (the "License"); +// // you may not use this file except in compliance with the License. +// // You may obtain a copy of the License at +// // http://www.apache.org/licenses/LICENSE-2.0 +// // Unless required by applicable law or agreed to in writing, software +// // distributed under the License is distributed on an "AS IS" BASIS, +// // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// // See the License for the specific language governing permissions and +// // limitations under the License. +// // ------------------------------------------------------------------------ +// +// namespace Dapr.Workflow.Test; +// +// using Moq; +// using System.Threading.Tasks; +// using Xunit; +// using Xunit.Sdk; +// +// /// +// /// Contains tests for WorkflowActivityContext. +// /// +// public class WorkflowActivityTest +// { +// private IWorkflowActivity workflowActivity; +// +// private Mock workflowActivityContextMock; +// +// public WorkflowActivityTest() +// { +// this.workflowActivity = new TestDaprWorkflowActivity(); +// this.workflowActivityContextMock = new Mock(); +// } +// +// [Fact] +// public async Task RunAsync_ShouldReturnCorrectContextInstanceId() +// { +// this.workflowActivityContextMock.Setup((x) => x.InstanceId).Returns("instanceId"); +// +// string result = (string) (await this.workflowActivity.RunAsync(this.workflowActivityContextMock.Object, "input"))!; +// +// Assert.Equal("instanceId", result); +// } +// +// +// public class TestDaprWorkflowActivity : WorkflowActivity +// { +// public override Task RunAsync(WorkflowActivityContext context, string input) +// { +// return Task.FromResult(context.InstanceId); +// } +// } +// } diff --git a/test/Dapr.Workflow.Test/WorkflowRuntimeOptionsTests.cs b/test/Dapr.Workflow.Test/WorkflowRuntimeOptionsTests.cs index 9aa3b941c..af97b8e2d 100644 --- a/test/Dapr.Workflow.Test/WorkflowRuntimeOptionsTests.cs +++ b/test/Dapr.Workflow.Test/WorkflowRuntimeOptionsTests.cs @@ -11,55 +11,156 @@ // limitations under the License. // ------------------------------------------------------------------------ +using Dapr.Workflow.Abstractions; +using Dapr.Workflow.Worker; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + namespace Dapr.Workflow.Test; public class WorkflowRuntimeOptionsTests { [Fact] - public void RegisterWorkflow_WithoutName_AddsWorkflowWithTypeName() + public void MaxConcurrentWorkflows_ShouldThrow_WhenSetLessThan1() { var options = new WorkflowRuntimeOptions(); - options.RegisterWorkflow(); - Assert.Contains(typeof(TestWorkflow).Name, options.FactoriesInternal); + + Assert.Throws(() => options.MaxConcurrentWorkflows = 0); } [Fact] - public void RegisterWorkflow_WithName_AddsWorkflowWithSpecifiedName() + public void MaxConcurrentActivities_ShouldThrow_WhenSetLessThan1() { var options = new WorkflowRuntimeOptions(); - options.RegisterWorkflow("MyWorkflow_v1.0"); - Assert.Contains("MyWorkflow_v1.0", options.FactoriesInternal); + + Assert.Throws(() => options.MaxConcurrentActivities = 0); } [Fact] - public void RegisterActivity_WithoutName_AddsWorkflowActivityWithTypeName() + public void UseGrpcChannelOptions_ShouldThrowArgumentNullException_WhenNull() { var options = new WorkflowRuntimeOptions(); - options.RegisterActivity(); - Assert.Contains(typeof(TestWorkflowActivity).Name, options.FactoriesInternal); + + Assert.Throws(() => options.UseGrpcChannelOptions(null!)); } [Fact] - public void RegisterActivity_WithName_AddsWorkflowActivityWithSpecifiedName() + public void ApplyRegistrations_ShouldThrowArgumentNullException_WhenFactoryIsNull() { var options = new WorkflowRuntimeOptions(); - options.RegisterActivity("MyActivity_v1.0"); - Assert.Contains("MyActivity_v1.0", options.FactoriesInternal); + + Assert.Throws(() => options.ApplyRegistrations(null!)); } - public class TestWorkflow : Workflow + [Fact] + public void UseGrpcChannelOptions_ShouldStoreOptions_ForLaterUse() { - public override Task RunAsync(WorkflowContext context, string input) - { - return Task.FromResult(input); - } + var options = new WorkflowRuntimeOptions(); + var grpcOptions = new GrpcChannelOptions { MaxReceiveMessageSize = 123 }; + + options.UseGrpcChannelOptions(grpcOptions); + + Assert.NotNull(options.GetType().GetProperty("GrpcChannelOptions", System.Reflection.BindingFlags.Instance | System.Reflection.BindingFlags.NonPublic)); } - public class TestWorkflowActivity : WorkflowActivity + [Fact] + public void ApplyRegistrations_ShouldApplyWorkflowAndActivityRegistrations_ToFactory() { - public override Task RunAsync(WorkflowActivityContext context, string input) - { - return Task.FromResult(input); - } + var options = new WorkflowRuntimeOptions(); + + options.RegisterWorkflow("wf-fn", (_, x) => Task.FromResult(x + 1)); + options.RegisterActivity("act-fn", (_, x) => Task.FromResult(x + 2)); + + var factory = new WorkflowsFactory(NullLogger.Instance); + options.ApplyRegistrations(factory); + + var sp = new Microsoft.Extensions.DependencyInjection.ServiceCollection().BuildServiceProvider(); + + Assert.True(factory.TryCreateWorkflow(new("wf-fn"), sp, out var workflow)); + Assert.NotNull(workflow); + + Assert.True(factory.TryCreateActivity(new("act-fn"), sp, out var activity)); + Assert.NotNull(activity); + } + + [Fact] + public void MaxConcurrentWorkflows_ShouldDefaultTo100_AndAllowSettingValidValues() + { + var options = new WorkflowRuntimeOptions(); + + Assert.Equal(100, options.MaxConcurrentWorkflows); + + options.MaxConcurrentWorkflows = 1; + Assert.Equal(1, options.MaxConcurrentWorkflows); + + options.MaxConcurrentWorkflows = 250; + Assert.Equal(250, options.MaxConcurrentWorkflows); + } + + [Fact] + public void MaxConcurrentActivities_ShouldDefaultTo100_AndAllowSettingValidValues() + { + var options = new WorkflowRuntimeOptions(); + + Assert.Equal(100, options.MaxConcurrentActivities); + + options.MaxConcurrentActivities = 1; + Assert.Equal(1, options.MaxConcurrentActivities); + + options.MaxConcurrentActivities = 250; + Assert.Equal(250, options.MaxConcurrentActivities); + } + + [Fact] + public void RegisterWorkflow_GenericWithName_ShouldRegisterUsingProvidedName_WhenApplied() + { + var options = new WorkflowRuntimeOptions(); + options.RegisterWorkflow(name: "MyCustomWorkflowName"); + + var factory = new WorkflowsFactory(NullLogger.Instance); + options.ApplyRegistrations(factory); + + var sp = new ServiceCollection().BuildServiceProvider(); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier("MyCustomWorkflowName"), sp, out var workflow)); + Assert.NotNull(workflow); + Assert.IsType(workflow); + + Assert.False(factory.TryCreateWorkflow(new TaskIdentifier(nameof(TestWorkflow)), sp, out _)); + } + + [Fact] + public void RegisterActivity_GenericWithName_ShouldRegisterUsingProvidedName_WhenApplied() + { + var options = new WorkflowRuntimeOptions(); + options.RegisterActivity(name: "MyCustomActivityName"); + + var factory = new WorkflowsFactory(NullLogger.Instance); + options.ApplyRegistrations(factory); + + var sp = new ServiceCollection().BuildServiceProvider(); + + Assert.True(factory.TryCreateActivity(new TaskIdentifier("MyCustomActivityName"), sp, out var activity)); + Assert.NotNull(activity); + Assert.IsType(activity); + + Assert.False(factory.TryCreateActivity(new TaskIdentifier(nameof(TestActivity)), sp, out _)); + } + + private sealed class TestWorkflow : IWorkflow + { + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + + public Task RunAsync(WorkflowContext context, object? input) => Task.FromResult(null); + } + + private sealed class TestActivity : IWorkflowActivity + { + public Type InputType => typeof(object); + public Type OutputType => typeof(object); + + public Task RunAsync(WorkflowActivityContext context, object? input) => Task.FromResult(null); } } diff --git a/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs b/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs index 2206d939b..295a32fc7 100644 --- a/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs +++ b/test/Dapr.Workflow.Test/WorkflowServiceCollectionExtensionsTests.cs @@ -1,58 +1,311 @@ -using Microsoft.Extensions.DependencyInjection; +using System.Text.Json; +using Dapr.DurableTask.Protobuf; +using Dapr.Workflow.Abstractions; +using Dapr.Workflow.Client; +using Dapr.Workflow.Serialization; +using Dapr.Workflow.Worker; +using Grpc.Core; +using Grpc.Net.Client; +using Grpc.Net.ClientFactory; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using Moq; namespace Dapr.Workflow.Test; public class WorkflowServiceCollectionExtensionsTests { [Fact] - public void RegisterWorkflowClient_ShouldRegisterSingleton_WhenLifetimeIsSingleton() + public void AddDaprWorkflow_Parameterless_ShouldThrowArgumentNullException_WhenServiceCollectionIsNull() + { + IServiceCollection services = null!; + Assert.Throws(() => services.AddDaprWorkflow()); + } + + [Fact] + public void AddDaprWorkflow_ShouldThrowArgumentNullException_WhenServiceCollectionIsNull() + { + IServiceCollection services = null!; + Assert.Throws(() => services.AddDaprWorkflow(_ => { })); + } + + [Fact] + public void AddDaprWorkflow_ShouldThrowArgumentNullException_WhenConfigureIsNull() + { + var services = new ServiceCollection(); + Assert.Throws(() => services.AddDaprWorkflow(null!)); + } + + [Fact] + public void AddDaprWorkflow_ShouldThrowArgumentOutOfRangeException_WhenLifetimeIsInvalid() + { + var services = new ServiceCollection(); + Assert.Throws(() => services.AddDaprWorkflow(_ => { }, (ServiceLifetime)999)); + } + + [Fact] + public void AddDaprWorkflowBuilder_ShouldThrowArgumentNullException_WhenServiceCollectionIsNull() + { + IServiceCollection services = null!; + Assert.Throws(() => services.AddDaprWorkflowBuilder(null)); + } + + [Fact] + public void WithSerializer_InstanceOverload_ShouldThrowArgumentNullException_WhenSerializerIsNull() + { + var services = new ServiceCollection(); + var builder = services.AddDaprWorkflowBuilder(null); + + Assert.Throws(() => builder.WithSerializer((IWorkflowSerializer)null!)); + } + + [Fact] + public void WithSerializer_FactoryOverload_ShouldThrowArgumentNullException_WhenFactoryIsNull() + { + var services = new ServiceCollection(); + var builder = services.AddDaprWorkflowBuilder(null); + + Assert.Throws(() => builder.WithSerializer((Func)null!)); + } + + [Fact] + public void WithJsonSerializer_ShouldThrowArgumentNullException_WhenJsonOptionsIsNull() + { + var services = new ServiceCollection(); + var builder = services.AddDaprWorkflowBuilder(null); + + Assert.Throws(() => builder.WithJsonSerializer(null!)); + } + + [Fact] + public void AddDaprWorkflow_ShouldNotOverrideCustomSerializer_WhenUserRegistersSerializerBeforeCallingAddDaprWorkflow() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddSingleton(MockSerializer.Instance); + services.AddDaprWorkflow(_ => { }); + + var sp = services.BuildServiceProvider(); + var serializer = sp.GetRequiredService(); + + Assert.Same(MockSerializer.Instance, serializer); + } + + [Fact] + public void AddDaprWorkflowBuilder_WithJsonSerializer_ShouldReplaceDefaultSerializer() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services + .AddDaprWorkflowBuilder(_ => { }) + .WithJsonSerializer(new JsonSerializerOptions { PropertyNamingPolicy = null }); + + var sp = services.BuildServiceProvider(); + var serializer = sp.GetRequiredService(); + + Assert.IsType(serializer); + } + + [Fact] + public void AddDaprWorkflowBuilder_WithSerializerFactory_ShouldResolveDependenciesFromServiceProvider() + { + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)); + + services.AddSingleton(new SerializerDependency("dep-1")); + + services + .AddDaprWorkflowBuilder(_ => { }) + .WithSerializer(sp => + { + var dep = sp.GetRequiredService(); + return new DependencyBasedSerializer(dep); + }); + + var sp = services.BuildServiceProvider(); + var serializer = sp.GetRequiredService(); + + var typed = Assert.IsType(serializer); + Assert.Equal("dep-1", typed.Dep.Value); + } + + [Fact] + public void AddDaprWorkflow_ShouldApplyGrpcChannelOptionsIntoGrpcClientFactoryOptions() + { + var services = new ServiceCollection(); + services.AddLogging(); + + services.AddDaprWorkflow(options => + { + options.UseGrpcChannelOptions(new GrpcChannelOptions + { + MaxReceiveMessageSize = 1234, + MaxSendMessageSize = 5678 + }); + }); + + var sp = services.BuildServiceProvider(); + + var monitor = sp.GetRequiredService>(); + + var clientType = typeof(TaskHubSidecarService.TaskHubSidecarServiceClient); + + var grpcOptions = + monitor.Get(clientType.FullName!) + ?? monitor.Get(clientType.Name); + + if (grpcOptions.ChannelOptionsActions.Count == 0) + { + grpcOptions = monitor.Get(clientType.Name); + } + + Assert.NotNull(grpcOptions); + Assert.NotEmpty(grpcOptions.ChannelOptionsActions); + + var channelOptions = new GrpcChannelOptions(); + foreach (var action in grpcOptions.ChannelOptionsActions) + { + action(channelOptions); + } + + Assert.Equal(1234, channelOptions.MaxReceiveMessageSize); + Assert.Equal(5678, channelOptions.MaxSendMessageSize); + } + + [Fact] + public async Task AddDaprWorkflow_ShouldCreateWorkflowsFactory_AndApplyRegistrationsFromOptions() { var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)); - services.AddDaprWorkflow(options => { }, ServiceLifetime.Singleton); - var serviceProvider = services.BuildServiceProvider(); + services.AddDaprWorkflow(options => + { + options.RegisterWorkflow("wf", (_, x) => Task.FromResult(x + 1)); + options.RegisterActivity("act", (_, x) => Task.FromResult(x + 2)); + }); - var daprWorkflowClient1 = serviceProvider.GetService(); - var daprWorkflowClient2 = serviceProvider.GetService(); + var sp = services.BuildServiceProvider(); - Assert.NotNull(daprWorkflowClient1); - Assert.NotNull(daprWorkflowClient2); - - Assert.Same(daprWorkflowClient1, daprWorkflowClient2); + var factory = sp.GetRequiredService(); + + Assert.True(factory.TryCreateWorkflow(new TaskIdentifier("wf"), sp, out var wf)); + Assert.NotNull(wf); + + var wfResult = await wf!.RunAsync(new FakeWorkflowContext(), 10); + Assert.Equal(11, wfResult); + + Assert.True(factory.TryCreateActivity(new TaskIdentifier("act"), sp, out var act)); + Assert.NotNull(act); + + var actResult = await act!.RunAsync(new FakeActivityContext(), 10); + Assert.Equal(12, actResult); } [Fact] - public async Task RegisterWorkflowClient_ShouldRegisterScoped_WhenLifetimeIsScoped() + public void AddDaprWorkflow_ShouldRegisterWorkflowClientImplementation_AsWorkflowGrpcClient() { var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)); - services.AddDaprWorkflow(options => { }, ServiceLifetime.Scoped); - var serviceProvider = services.BuildServiceProvider(); + // Provide a concrete proto client so the WorkflowClient factory can be executed. + var callInvoker = new Mock(MockBehavior.Loose); + services.AddSingleton(new TaskHubSidecarService.TaskHubSidecarServiceClient(callInvoker.Object)); - await using var scope1 = serviceProvider.CreateAsyncScope(); - var daprWorkflowClient1 = scope1.ServiceProvider.GetService(); + services.AddDaprWorkflow(_ => { }); - await using var scope2 = serviceProvider.CreateAsyncScope(); - var daprWorkflowClient2 = scope2.ServiceProvider.GetService(); - - Assert.NotNull(daprWorkflowClient1); - Assert.NotNull(daprWorkflowClient2); - Assert.NotSame(daprWorkflowClient1, daprWorkflowClient2); + var sp = services.BuildServiceProvider(); + + var workflowClient = sp.GetRequiredService(); + + Assert.NotNull(workflowClient); + Assert.IsType(workflowClient); } [Fact] - public void RegisterWorkflowClient_ShouldRegisterTransient_WhenLifetimeIsTransient() + public void AddDaprWorkflow_ShouldRegisterWorkflowWorker_AsHostedService() + { + var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)); + + // These are required for the hosted service construction to be possible. + var callInvoker = new Mock(MockBehavior.Loose); + services.AddSingleton(new TaskHubSidecarService.TaskHubSidecarServiceClient(callInvoker.Object)); + + services.AddDaprWorkflow(_ => { }); + + var hostedDescriptors = services + .Where(d => d.ServiceType == typeof(IHostedService)) + .ToList(); + + Assert.Contains(hostedDescriptors, d => d.ImplementationType == typeof(WorkflowWorker)); + } + + [Theory] + [InlineData(ServiceLifetime.Singleton)] + [InlineData(ServiceLifetime.Scoped)] + [InlineData(ServiceLifetime.Transient)] + public void AddDaprWorkflow_ShouldRegisterDaprWorkflowClient_WithConfiguredLifetime(ServiceLifetime lifetime) { var services = new ServiceCollection(); + services.AddLogging(b => b.AddProvider(NullLoggerProvider.Instance)); - services.AddDaprWorkflow(options => { }, ServiceLifetime.Transient); - var serviceProvider = services.BuildServiceProvider(); + services.AddDaprWorkflow(_ => { }, lifetime); - var daprWorkflowClient1 = serviceProvider.GetService(); - var daprWorkflowClient2 = serviceProvider.GetService(); + var descriptor = services.SingleOrDefault(d => d.ServiceType == typeof(DaprWorkflowClient)); + Assert.NotNull(descriptor); + Assert.Equal(lifetime, descriptor!.Lifetime); + } + + private sealed record SerializerDependency(string Value); + + private sealed class DependencyBasedSerializer(SerializerDependency dep) : IWorkflowSerializer + { + public SerializerDependency Dep { get; } = dep; + + public string Serialize(object? value, Type? inputType = null) => "x"; + public T? Deserialize(string? data) => default; + public object? Deserialize(string? data, Type returnType) => null; + } + + private sealed class FakeWorkflowContext : WorkflowContext + { + public override string Name => "wf"; + public override string InstanceId => "i"; + public override DateTime CurrentUtcDateTime => DateTime.UtcNow; + public override bool IsReplaying => false; + + public override Task CallActivityAsync(string name, object? input = null, WorkflowTaskOptions? options = null) => throw new NotSupportedException(); + public override Task CreateTimer(DateTime fireAt, CancellationToken cancellationToken) => throw new NotSupportedException(); + public override Task WaitForExternalEventAsync(string eventName, CancellationToken cancellationToken = default) => throw new NotSupportedException(); + public override Task WaitForExternalEventAsync(string eventName, TimeSpan timeout) => throw new NotSupportedException(); + public override void SendEvent(string instanceId, string eventName, object payload) => throw new NotSupportedException(); + public override void SetCustomStatus(object? customStatus) => throw new NotSupportedException(); + public override Task CallChildWorkflowAsync(string workflowName, object? input = null, ChildWorkflowTaskOptions? options = null) => throw new NotSupportedException(); + public override void ContinueAsNew(object? newInput = null, bool preserveUnprocessedEvents = true) => throw new NotSupportedException(); + public override Guid NewGuid() => Guid.NewGuid(); + public override ILogger CreateReplaySafeLogger(string categoryName) => throw new NotSupportedException(); + public override ILogger CreateReplaySafeLogger(Type type) => throw new NotSupportedException(); + public override ILogger CreateReplaySafeLogger() => throw new NotSupportedException(); + } + + private sealed class FakeActivityContext : WorkflowActivityContext + { + public override TaskIdentifier Identifier => new("act"); + public override string TaskExecutionKey => "test-key"; + public override string InstanceId => "i"; + } + + private sealed class MockSerializer : IWorkflowSerializer + { + public static MockSerializer Instance { get; } = new(); - Assert.NotNull(daprWorkflowClient1); - Assert.NotNull(daprWorkflowClient2); - Assert.NotSame(daprWorkflowClient1, daprWorkflowClient2); + public string Serialize(object? value, Type? inputType = null) => "mock"; + public T? Deserialize(string? data) => default; + public object? Deserialize(string? data, Type returnType) => null; } } diff --git a/test/Dapr.Workflow.Test/WorkflowStateTests.cs b/test/Dapr.Workflow.Test/WorkflowStateTests.cs new file mode 100644 index 000000000..5fc99930c --- /dev/null +++ b/test/Dapr.Workflow.Test/WorkflowStateTests.cs @@ -0,0 +1,77 @@ +// ------------------------------------------------------------------------ +// Copyright 2025 The Dapr Authors +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// http://www.apache.org/licenses/LICENSE-2.0 +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// ------------------------------------------------------------------------ + +using Dapr.Workflow.Client; +using Dapr.Workflow.Serialization; + +namespace Dapr.Workflow.Test; + +public class WorkflowStateTests +{ + [Fact] + public void Properties_ShouldReturnDefaults_WhenMetadataIsNull() + { + var state = new WorkflowState(null); + + Assert.False(state.Exists); + Assert.False(state.IsWorkflowRunning); + Assert.False(state.IsWorkflowCompleted); + Assert.Equal(DateTime.MinValue, state.CreatedAt.DateTime); + Assert.Equal(DateTime.MinValue, state.LastUpdatedAt.DateTime); + Assert.Equal(WorkflowRuntimeStatus.Unknown, state.RuntimeStatus); + Assert.Null(state.FailureDetails); + + Assert.Equal(default, state.ReadInputAs()); + Assert.Equal(default, state.ReadOutputAs()); + Assert.Equal(default, state.ReadCustomStatusAs()); + } + + [Fact] + public void Properties_ShouldReflectMetadata_WhenPresent() + { + var serializer = new JsonWorkflowSerializer(); + var created = new DateTime(2025, 01, 01, 0, 0, 0, DateTimeKind.Utc); + var updated = new DateTime(2025, 01, 02, 0, 0, 0, DateTimeKind.Utc); + + var metadata = new WorkflowMetadata( + InstanceId: "i", + Name: "wf", + RuntimeStatus: WorkflowRuntimeStatus.Running, + CreatedAt: created, + LastUpdatedAt: updated, + Serializer: serializer); + + var state = new WorkflowState(metadata); + + Assert.True(state.Exists); + Assert.True(state.IsWorkflowRunning); + Assert.False(state.IsWorkflowCompleted); + Assert.Equal(created, state.CreatedAt); + Assert.Equal(updated, state.LastUpdatedAt); + Assert.Equal(WorkflowRuntimeStatus.Running, state.RuntimeStatus); + } + + [Theory] + [InlineData(WorkflowRuntimeStatus.Completed)] + [InlineData(WorkflowRuntimeStatus.Failed)] + [InlineData(WorkflowRuntimeStatus.Terminated)] + public void IsWorkflowCompleted_ShouldBeTrue_ForTerminalStatuses(WorkflowRuntimeStatus status) + { + var serializer = new JsonWorkflowSerializer(); + var metadata = new WorkflowMetadata("i", "wf", status, DateTime.MinValue, DateTime.MinValue, serializer); + + var state = new WorkflowState(metadata); + + Assert.True(state.IsWorkflowCompleted); + } +}