├── .editorconfig ├── .github ├── policies │ └── resourceManagement.yml └── workflows │ ├── build-and-test.yml │ ├── codeQL.yml │ └── publish-release.yml ├── .gitignore ├── CHANGELOG.md ├── Directory.Build.targets ├── Directory.Packages.props ├── DurableTask.SqlServer.sln ├── LICENSE ├── README.md ├── SECURITY.md ├── azure-pipelines.yml ├── docs ├── .nojekyll ├── architecture.md ├── index.html ├── introduction.md ├── kubernetes.md ├── media │ ├── arch-diagram.png │ ├── logo.png │ ├── schema.png │ └── throughput.png ├── multitenancy.md ├── quickstart.md ├── scaling.md ├── sidebar.md └── taskhubs.md ├── eng ├── ci │ ├── code-mirror.yml │ ├── official-build.yml │ └── publish.yml └── templates │ └── build.yml ├── nuget.config ├── sign.snk ├── src ├── DurableTask.SqlServer.AzureFunctions │ ├── DurableTask.SqlServer.AzureFunctions.csproj │ ├── SqlDurabilityOptions.cs │ ├── SqlDurabilityProvider.cs │ ├── SqlDurabilityProviderExtensions.cs │ ├── SqlDurabilityProviderFactory.cs │ ├── SqlDurabilityProviderStartup.cs │ ├── SqlMetricsProvider.cs │ ├── SqlScaleMetric.cs │ ├── SqlScaleMonitor.cs │ └── SqlTargetScaler.cs ├── DurableTask.SqlServer │ ├── AssemblyInfo.cs │ ├── BackoffPollingHelper.cs │ ├── DTUtils.cs │ ├── DbTaskEvent.cs │ ├── DurableTask.SqlServer.csproj │ ├── EventPayloadMap.cs │ ├── LogHelper.cs │ ├── Logging │ │ ├── DefaultEventSource.cs │ │ ├── EventIds.cs │ │ └── LogEvents.cs │ ├── Scripts │ │ ├── README.md │ │ ├── drop-schema.sql │ │ ├── logic.sql │ │ ├── permissions.sql │ │ ├── schema-1.0.0.sql │ │ └── schema-1.2.0.sql │ ├── SqlDbManager.cs │ ├── SqlIdentifier.cs │ ├── SqlOrchestrationQuery.cs │ ├── SqlOrchestrationService.cs │ ├── SqlOrchestrationServiceSettings.cs │ ├── SqlTypes │ │ ├── HistoryEventSqlType.cs │ │ ├── MessageIdSqlType.cs │ │ ├── OrchestrationEventSqlType.cs │ │ └── TaskEventSqlType.cs │ ├── SqlUtils.cs │ └── Utils │ │ ├── AsyncAutoResetEvent.cs │ │ ├── NetFxCompat.cs │ │ └── OrchestrationServiceBase.cs ├── Functions.Worker.Extensions.DurableTask.SqlServer │ └── Functions.Worker.Extensions.DurableTask.SqlServer.csproj └── common.props ├── test ├── DurableTask.SqlServer.AzureFunctions.Tests │ ├── CoreScenarios.cs │ ├── DurableTask.SqlServer.AzureFunctions.Tests.csproj │ ├── Functions.cs │ ├── IntegrationTestBase.cs │ ├── TargetBasedScalingTests.cs │ ├── Utils.cs │ └── WithoutMultiTenancyCoreScenarios.cs ├── DurableTask.SqlServer.Tests │ ├── DatabaseBackups │ │ └── DurableDB-v1.0.0.bak.zip │ ├── DurableTask.SqlServer.Tests.csproj │ ├── Integration │ │ ├── DataRetentionTests.cs │ │ ├── DatabaseManagement.cs │ │ ├── FaultTesting.cs │ │ ├── LegacyErrorPropagation.cs │ │ ├── Orchestrations.cs │ │ ├── ScaleTests.cs │ │ ├── StressTests.cs │ │ └── UpgradeTests.cs │ ├── Logging │ │ ├── LogAssert.cs │ │ ├── LogAssertExtensions.cs │ │ ├── LogEntry.cs │ │ └── TestLogProvider.cs │ ├── Unit │ │ └── SqlIdentifierTests.cs │ └── Utils │ │ ├── SharedTestHelpers.cs │ │ ├── TestCredential.cs │ │ ├── TestInstance.cs │ │ └── TestService.cs ├── PerformanceTests │ ├── .dockerignore │ ├── Common.cs │ ├── Dockerfile │ ├── LongHaul.cs │ ├── ManyEntities.cs │ ├── ManyMixedOrchestrations.cs │ ├── ManySequences.cs │ ├── PerformanceTests.csproj │ ├── PurgeOrchestrationData.cs │ ├── Scripts │ │ └── RunTestInAzure.ps1 │ ├── host.json │ └── local.settings.json └── setup.ps1 └── tools └── TestDBGenerator ├── Orchestrations.cs ├── Program.cs ├── Properties └── launchSettings.json └── TestDBGenerator.csproj /.github/policies/resourceManagement.yml: -------------------------------------------------------------------------------- 1 | id: 2 | name: GitOps.PullRequestIssueManagement 3 | description: GitOps.PullRequestIssueManagement primitive 4 | owner: 5 | resource: repository 6 | disabled: false 7 | where: 8 | configuration: 9 | resourceManagementConfiguration: 10 | scheduledSearches: 11 | - description: 12 | frequencies: 13 | - hourly: 14 | hour: 3 15 | filters: 16 | - isIssue 17 | - isOpen 18 | - hasLabel: 19 | label: "Needs: Author Feedback" 20 | - hasLabel: 21 | label: no-recent-activity 22 | - noActivitySince: 23 | days: 3 24 | actions: 25 | - closeIssue 26 | - description: 27 | frequencies: 28 | - hourly: 29 | hour: 3 30 | filters: 31 | - isIssue 32 | - isOpen 33 | - hasLabel: 34 | label: "Needs: Author Feedback" 35 | - noActivitySince: 36 | days: 4 37 | - isNotLabeledWith: 38 | label: no-recent-activity 39 | actions: 40 | - addLabel: 41 | label: no-recent-activity 42 | - addReply: 43 | reply: This issue has been automatically marked as stale because it has been marked as requiring author feedback but has not had any activity for **4 days**. It will be closed if no further activity occurs **within 3 days of this comment**. 44 | - description: 45 | frequencies: 46 | - hourly: 47 | hour: 3 48 | filters: 49 | - isIssue 50 | - isOpen 51 | - hasLabel: 52 | label: duplicate 53 | - noActivitySince: 54 | days: 1 55 | actions: 56 | - addReply: 57 | reply: This issue has been marked as duplicate and has not had any activity for **1 day**. It will be closed for housekeeping purposes. 58 | - closeIssue 59 | eventResponderTasks: 60 | - if: 61 | - payloadType: Issues 62 | - and: 63 | - isOpen 64 | - not: 65 | and: 66 | - isLabeled 67 | then: 68 | - addLabel: 69 | label: "Needs: Triage :mag:" 70 | - if: 71 | - payloadType: Issue_Comment 72 | - isAction: 73 | action: Created 74 | - isActivitySender: 75 | issueAuthor: True 76 | - hasLabel: 77 | label: "Needs: Author Feedback" 78 | then: 79 | - addLabel: 80 | label: "Needs: Attention :wave:" 81 | - removeLabel: 82 | label: "Needs: Author Feedback" 83 | description: 84 | - if: 85 | - payloadType: Issues 86 | - not: 87 | isAction: 88 | action: Closed 89 | - hasLabel: 90 | label: no-recent-activity 91 | then: 92 | - removeLabel: 93 | label: no-recent-activity 94 | description: 95 | - if: 96 | - payloadType: Issue_Comment 97 | - hasLabel: 98 | label: no-recent-activity 99 | then: 100 | - removeLabel: 101 | label: no-recent-activity 102 | description: 103 | onFailure: 104 | onSuccess: 105 | -------------------------------------------------------------------------------- /.github/workflows/build-and-test.yml: -------------------------------------------------------------------------------- 1 | name: Build and Test 2 | 3 | on: 4 | push: 5 | branches: [ main, testing ] 6 | paths-ignore: 7 | - '**.md' 8 | pull_request: 9 | branches: [ main, testing ] 10 | paths-ignore: 11 | - '**.md' 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | env: 18 | SA_PASSWORD: NotASecret!12 # ([SuppressMessage\("Microsoft.Security", "CS001:SecretInline", Justification="This isn't a real prod secret, it is a local DB instance instantiated on demand."\)] 19 | 20 | steps: 21 | - uses: actions/checkout@v4 22 | 23 | - name: Setup .NET 6 24 | uses: actions/setup-dotnet@v3 25 | with: 26 | dotnet-version: '6.0.x' 27 | env: 28 | NUGET_AUTH_TOKEN: RequiredButNotUsed 29 | 30 | - name: NuGet Restore 31 | run: dotnet restore -v n 32 | 33 | - name: Build 34 | run: dotnet build 35 | 36 | - name: Setup SQL Server container 37 | run: test/setup.ps1 38 | shell: pwsh 39 | 40 | - name: Durable framework tests 41 | run: dotnet test --no-build --verbosity normal --filter Category!=Stress ./test/DurableTask.SqlServer.Tests/DurableTask.SqlServer.Tests.csproj 42 | - name: Functions runtime tests 43 | run: dotnet test --no-build --verbosity normal ./test/DurableTask.SqlServer.AzureFunctions.Tests/DurableTask.SqlServer.AzureFunctions.Tests.csproj 44 | -------------------------------------------------------------------------------- /.github/workflows/codeQL.yml: -------------------------------------------------------------------------------- 1 | # This workflow generates weekly CodeQL reports for this repo, a security requirements. 2 | # The workflow is adapted from the following reference: https://github.com/Azure-Samples/azure-functions-python-stream-openai/pull/2/files 3 | # Generic comments on how to modify these file are left intactfor future maintenance. 4 | 5 | name: "CodeQL" 6 | 7 | on: 8 | push: 9 | branches: [ "main" ] 10 | pull_request: 11 | branches: [ "main"] 12 | schedule: 13 | - cron: '0 0 * * 1' # Weekly Monday run, needed for weekly reports 14 | workflow_call: # allows to be invoked as part of a larger workflow 15 | workflow_dispatch: # allows for the workflow to run manually see: https://docs.github.com/en/actions/using-workflows/manually-running-a-workflow 16 | 17 | env: 18 | solution: DurableTask.sln 19 | config: Release 20 | 21 | jobs: 22 | invoke-build-workflow: # Call re-useable build workflow 23 | uses: ./.github/workflows/build.yml 24 | 25 | analyze: 26 | name: Analyze 27 | needs: invoke-build-workflow # Can only test after build completes 28 | 29 | runs-on: windows-latest 30 | permissions: 31 | actions: read 32 | contents: read 33 | security-events: write 34 | 35 | 36 | strategy: 37 | fail-fast: false 38 | matrix: 39 | language: ['csharp'] 40 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 41 | # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support 42 | 43 | steps: 44 | # - name: Checkout repository 45 | # uses: actions/checkout@v3 46 | 47 | - name: Download built-code 48 | uses: actions/download-artifact@v2 49 | with: 50 | name: built-code 51 | path: ./ # This path will match the upload path 52 | 53 | # Initializes the CodeQL tools for scanning. 54 | - name: Initialize CodeQL 55 | uses: github/codeql-action/init@v3 56 | with: 57 | languages: ${{ matrix.language }} 58 | # If you wish to specify custom queries, you can do so here or in a config file. 59 | # By default, queries listed here will override any specified in a config file. 60 | # Prefix the list here with "+" to use these queries and those in the config file. 61 | 62 | # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs 63 | # queries: security-extended,security-and-quality 64 | 65 | # Run CodeQL analysis 66 | - name: Perform CodeQL Analysis 67 | uses: github/codeql-action/analyze@v3 68 | with: 69 | category: "/language:${{matrix.language}}" -------------------------------------------------------------------------------- /.github/workflows/publish-release.yml: -------------------------------------------------------------------------------- 1 | name: Publish Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - v* #version is cut 7 | 8 | env: 9 | DOTNET_VERSION: "6.0.x" 10 | GITHUB_SOURCE: "https://nuget.pkg.github.com/microsoft/index.json" 11 | CONFIGURATION: Release 12 | 13 | jobs: 14 | build: 15 | 16 | runs-on: ubuntu-latest 17 | 18 | steps: 19 | - uses: actions/checkout@v2 20 | - name: Setup .NET Core ${{ env.DOTNET_VERSION }} 21 | uses: actions/setup-dotnet@v1 22 | with: 23 | dotnet-version: ${{ env.DOTNET_VERSION }} 24 | - name: Install dependencies 25 | run: dotnet restore 26 | - name: Build 27 | run: dotnet build --configuration ${{ env.CONFIGURATION }} --no-restore 28 | - name: Publish 29 | run: dotnet nuget push --api-key ${{ secrets.GITHUB_TOKEN }} --source ${{ env.GITHUB_SOURCE }} "**/*.nupkg" 30 | -------------------------------------------------------------------------------- /Directory.Build.targets: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 8 | 9 | 16 | 17 | false 18 | <_TranslateUrlPattern>(https://azfunc%40dev\.azure\.com/azfunc/internal/_git|https://dev\.azure\.com/azfunc/internal/_git|https://azfunc\.visualstudio\.com/internal/_git|azfunc%40vs-ssh\.visualstudio\.com:v3/azfunc/internal|git%40ssh\.dev\.azure\.com:v3/azfunc/internal)/([^/\.]+)\.(.+) 19 | <_TranslateUrlReplacement>https://github.com/$2/$3 20 | 21 | 22 | 23 | 27 | 28 | $([System.Text.RegularExpressions.Regex]::Replace($(ScmRepositoryUrl), $(_TranslateUrlPattern), $(_TranslateUrlReplacement))) 29 | 30 | 31 | 32 | $([System.Text.RegularExpressions.Regex]::Replace(%(SourceRoot.ScmRepositoryUrl), $(_TranslateUrlPattern), $(_TranslateUrlReplacement))) 33 | 34 | 35 | 36 | 37 | -------------------------------------------------------------------------------- /Directory.Packages.props: -------------------------------------------------------------------------------- 1 |  2 | 3 | 7 | true 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /DurableTask.SqlServer.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.3.32929.385 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DurableTask.SqlServer", "src\DurableTask.SqlServer\DurableTask.SqlServer.csproj", "{FC7FB0AF-2322-4356-AF64-A8E2EB7D1EF8}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DurableTask.SqlServer.Tests", "test\DurableTask.SqlServer.Tests\DurableTask.SqlServer.Tests.csproj", "{3D282458-56C8-4022-A37C-6B67F4EF9BF4}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{FFF00AD0-D467-4D12-AB79-BEF19BA527C0}" 11 | ProjectSection(SolutionItems) = preProject 12 | .editorconfig = .editorconfig 13 | .gitignore = .gitignore 14 | azure-pipelines.yml = azure-pipelines.yml 15 | .github\workflows\build-and-test.yml = .github\workflows\build-and-test.yml 16 | CHANGELOG.md = CHANGELOG.md 17 | src\common.props = src\common.props 18 | Directory.Build.targets = Directory.Build.targets 19 | Directory.Packages.props = Directory.Packages.props 20 | nuget.config = nuget.config 21 | README.md = README.md 22 | sign.snk = sign.snk 23 | EndProjectSection 24 | EndProject 25 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DurableTask.SqlServer.AzureFunctions", "src\DurableTask.SqlServer.AzureFunctions\DurableTask.SqlServer.AzureFunctions.csproj", "{FC03BDA8-8A73-45F4-8D21-25F739BFF9E1}" 26 | EndProject 27 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DurableTask.SqlServer.AzureFunctions.Tests", "test\DurableTask.SqlServer.AzureFunctions.Tests\DurableTask.SqlServer.AzureFunctions.Tests.csproj", "{1809EEF7-0772-404A-96C2-D76D80F1D191}" 28 | EndProject 29 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "PerformanceTests", "test\PerformanceTests\PerformanceTests.csproj", "{DD1E1B3F-4FA2-4F3A-9AE1-6B2A0B864AAF}" 30 | EndProject 31 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{D33AB157-04B9-4BAD-B580-C3C87C17828C}" 32 | EndProject 33 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "test", "test", "{4A7226CF-57BF-4CA3-A4AC-91A398A1D84B}" 34 | ProjectSection(SolutionItems) = preProject 35 | test\setup.ps1 = test\setup.ps1 36 | EndProjectSection 37 | EndProject 38 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestDBGenerator", "tools\TestDBGenerator\TestDBGenerator.csproj", "{28117755-60C3-463D-A32D-E0A38E9E4ADA}" 39 | EndProject 40 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tools", "tools", "{C6E6ACAB-F123-4D18-891E-DE9C44539153}" 41 | EndProject 42 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Functions.Worker.Extensions.DurableTask.SqlServer", "src\Functions.Worker.Extensions.DurableTask.SqlServer\Functions.Worker.Extensions.DurableTask.SqlServer.csproj", "{307C5A62-9943-48B8-8513-BBCDF0FBC3D0}" 43 | EndProject 44 | Global 45 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 46 | Debug|Any CPU = Debug|Any CPU 47 | Release|Any CPU = Release|Any CPU 48 | EndGlobalSection 49 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 50 | {FC7FB0AF-2322-4356-AF64-A8E2EB7D1EF8}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 51 | {FC7FB0AF-2322-4356-AF64-A8E2EB7D1EF8}.Debug|Any CPU.Build.0 = Debug|Any CPU 52 | {FC7FB0AF-2322-4356-AF64-A8E2EB7D1EF8}.Release|Any CPU.ActiveCfg = Release|Any CPU 53 | {FC7FB0AF-2322-4356-AF64-A8E2EB7D1EF8}.Release|Any CPU.Build.0 = Release|Any CPU 54 | {3D282458-56C8-4022-A37C-6B67F4EF9BF4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 55 | {3D282458-56C8-4022-A37C-6B67F4EF9BF4}.Debug|Any CPU.Build.0 = Debug|Any CPU 56 | {3D282458-56C8-4022-A37C-6B67F4EF9BF4}.Release|Any CPU.ActiveCfg = Release|Any CPU 57 | {3D282458-56C8-4022-A37C-6B67F4EF9BF4}.Release|Any CPU.Build.0 = Release|Any CPU 58 | {FC03BDA8-8A73-45F4-8D21-25F739BFF9E1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 59 | {FC03BDA8-8A73-45F4-8D21-25F739BFF9E1}.Debug|Any CPU.Build.0 = Debug|Any CPU 60 | {FC03BDA8-8A73-45F4-8D21-25F739BFF9E1}.Release|Any CPU.ActiveCfg = Release|Any CPU 61 | {FC03BDA8-8A73-45F4-8D21-25F739BFF9E1}.Release|Any CPU.Build.0 = Release|Any CPU 62 | {1809EEF7-0772-404A-96C2-D76D80F1D191}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 63 | {1809EEF7-0772-404A-96C2-D76D80F1D191}.Debug|Any CPU.Build.0 = Debug|Any CPU 64 | {1809EEF7-0772-404A-96C2-D76D80F1D191}.Release|Any CPU.ActiveCfg = Release|Any CPU 65 | {1809EEF7-0772-404A-96C2-D76D80F1D191}.Release|Any CPU.Build.0 = Release|Any CPU 66 | {DD1E1B3F-4FA2-4F3A-9AE1-6B2A0B864AAF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 67 | {DD1E1B3F-4FA2-4F3A-9AE1-6B2A0B864AAF}.Debug|Any CPU.Build.0 = Debug|Any CPU 68 | {DD1E1B3F-4FA2-4F3A-9AE1-6B2A0B864AAF}.Release|Any CPU.ActiveCfg = Release|Any CPU 69 | {DD1E1B3F-4FA2-4F3A-9AE1-6B2A0B864AAF}.Release|Any CPU.Build.0 = Release|Any CPU 70 | {28117755-60C3-463D-A32D-E0A38E9E4ADA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 71 | {28117755-60C3-463D-A32D-E0A38E9E4ADA}.Debug|Any CPU.Build.0 = Debug|Any CPU 72 | {28117755-60C3-463D-A32D-E0A38E9E4ADA}.Release|Any CPU.ActiveCfg = Release|Any CPU 73 | {28117755-60C3-463D-A32D-E0A38E9E4ADA}.Release|Any CPU.Build.0 = Release|Any CPU 74 | {307C5A62-9943-48B8-8513-BBCDF0FBC3D0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 75 | {307C5A62-9943-48B8-8513-BBCDF0FBC3D0}.Debug|Any CPU.Build.0 = Debug|Any CPU 76 | {307C5A62-9943-48B8-8513-BBCDF0FBC3D0}.Release|Any CPU.ActiveCfg = Release|Any CPU 77 | {307C5A62-9943-48B8-8513-BBCDF0FBC3D0}.Release|Any CPU.Build.0 = Release|Any CPU 78 | EndGlobalSection 79 | GlobalSection(SolutionProperties) = preSolution 80 | HideSolutionNode = FALSE 81 | EndGlobalSection 82 | GlobalSection(NestedProjects) = preSolution 83 | {FC7FB0AF-2322-4356-AF64-A8E2EB7D1EF8} = {D33AB157-04B9-4BAD-B580-C3C87C17828C} 84 | {3D282458-56C8-4022-A37C-6B67F4EF9BF4} = {4A7226CF-57BF-4CA3-A4AC-91A398A1D84B} 85 | {FC03BDA8-8A73-45F4-8D21-25F739BFF9E1} = {D33AB157-04B9-4BAD-B580-C3C87C17828C} 86 | {1809EEF7-0772-404A-96C2-D76D80F1D191} = {4A7226CF-57BF-4CA3-A4AC-91A398A1D84B} 87 | {DD1E1B3F-4FA2-4F3A-9AE1-6B2A0B864AAF} = {4A7226CF-57BF-4CA3-A4AC-91A398A1D84B} 88 | {28117755-60C3-463D-A32D-E0A38E9E4ADA} = {C6E6ACAB-F123-4D18-891E-DE9C44539153} 89 | {307C5A62-9943-48B8-8513-BBCDF0FBC3D0} = {D33AB157-04B9-4BAD-B580-C3C87C17828C} 90 | EndGlobalSection 91 | GlobalSection(ExtensibilityGlobals) = postSolution 92 | SolutionGuid = {238A9613-5411-41CF-BDEC-168CCD5C03FB} 93 | EndGlobalSection 94 | EndGlobal 95 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Microsoft Corporation 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 

2 | 3 | Durable Task SQL Provider 4 | 5 |

6 | 7 | # Microsoft SQL Provider for the Durable Task Framework and Durable Functions 8 | 9 | [![Build status](https://github.com/microsoft/durabletask-mssql/workflows/Build%20and%20Test/badge.svg)](https://github.com/microsoft/durabletask-mssql/actions?workflow=Build+and+Test) 10 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 11 | 12 | The Microsoft SQL provider for the [Durable Task Framework](https://github.com/Azure/durabletask) (DTFx) and [Azure Durable Functions](https://docs.microsoft.com/azure/azure-functions/durable/durable-functions-overview) is a storage provider that persists all task hub state in a Microsoft SQL database, which can be hosted in the cloud or in your own infrastructure. 13 | 14 | The key benefits of this storage provider include: 15 | 16 | * **Data portability**: This provider supports both Microsoft SQL Server and Azure SQL Database. Microsoft SQL Server is supported by all major cloud providers and can also be run in your own infrastructure. Because the data is stored in a single database, you can also easily backup the data and migrate it in a new server or service as necessary. 17 | 18 | * **Data control**: You have full control over the database, the logins, and have direct access to the runtime data, making it easy to protect and secure as necessary. Microsoft SQL also has great support for encryption and business continuity, ensuring that any apps you build can meet the compliance requirements of your enterprise. 19 | 20 | * **Multitenancy**: Multiple applications can share the same database in a way that isolates the data between each app using low-privilege SQL login credentials. 21 | 22 | * **3rd party app integrations**: This provider comes with a set of stored procedures, SQL functions, and views that allow you to easily integrate Durable orchestrations and entities into your existing SQL-based applications. 23 | 24 | ## Downloads 25 | 26 | The Durable SQL provider for Durable Functions and DTFx are available as NuGet packages. 27 | 28 | | Package | Latest Version | Description | 29 | | ------- | -------------- | ----------- | 30 | | Microsoft.Azure.Functions.Worker.Extensions.DurableTask.SqlServer | [![NuGet](https://img.shields.io/nuget/v/Microsoft.Azure.Functions.Worker.Extensions.DurableTask.SqlServer.svg?style=flat)](https://www.nuget.org/packages/Microsoft.Azure.Functions.Worker.Extensions.DurableTask.SqlServer/) | Use this package if using Azure Durable Functions with the .NET out-of-process worker. | 31 | | Microsoft.DurableTask.SqlServer.AzureFunctions | [![NuGet](https://img.shields.io/nuget/v/Microsoft.DurableTask.SqlServer.AzureFunctions.svg?style=flat)](https://www.nuget.org/packages/Microsoft.DurableTask.SqlServer.AzureFunctions/) | Use this package if building serverless Function apps with Azure Durable Functions (for everything _except_ the .NET out-of-process worker). | 32 | | Microsoft.DurableTask.SqlServer | [![NuGet](https://img.shields.io/nuget/v/Microsoft.DurableTask.SqlServer.svg?style=flat)](https://www.nuget.org/packages/Microsoft.DurableTask.SqlServer/) | Use this package if using DTFx to build .NET apps. | 33 | 34 | ## Documentation 35 | 36 | Want to learn more? Detailed information about this provider and getting started instructions can be found [here](https://microsoft.github.io/durabletask-mssql/). 37 | 38 | If you use Azure Durable Functions and want to learn more about all the supported storage provider options, see the [Durable Functions Storage Providers documentation](https://docs.microsoft.com/azure/azure-functions/durable/durable-functions-storage-providers). 39 | 40 | ## Contributing 41 | 42 | This project welcomes contributions and suggestions. Most contributions require you to agree to a [Contributor License Agreement (CLA)](https://cla.microsoft.com) declaring that you have the right to, and actually do, grant us the rights to use your contribution. 43 | 44 | When you submit a pull request, a CLA-bot will automatically determine whether you need to provide a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the instructions provided by the bot. You will only need to do this once across all repositories using our CLA. 45 | 46 | ## Running Tests 47 | 48 | Tests will attempt to connect to an instance of SQL Server installed on the local machine. When running on Windows, you'll need to ensure that [SQL Server Mixed Mode Authentication](https://docs.microsoft.com/ensql/database-engine/configure-windows/change-server-authentication-mode) is enabled. 49 | 50 | ## Code of Conduct 51 | 52 | This project has adopted the [Microsoft Open Source Code of conduct](https://opensource.microsoft.com/codeofconduct/). 53 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 54 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Security 4 | 5 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/Microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 6 | 7 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 8 | 9 | ## Reporting Security Issues 10 | 11 | **Please do not report security vulnerabilities through public GitHub issues.** 12 | 13 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 14 | 15 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 16 | 17 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 18 | 19 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 20 | 21 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 22 | * Full paths of source file(s) related to the manifestation of the issue 23 | * The location of the affected source code (tag/branch/commit or direct URL) 24 | * Any special configuration required to reproduce the issue 25 | * Step-by-step instructions to reproduce the issue 26 | * Proof-of-concept or exploit code (if possible) 27 | * Impact of the issue, including how an attacker might exploit the issue 28 | 29 | This information will help us triage your report more quickly. 30 | 31 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 32 | 33 | ## Preferred Languages 34 | 35 | We prefer all communications to be in English. 36 | 37 | ## Policy 38 | 39 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). 40 | 41 | 42 | -------------------------------------------------------------------------------- /azure-pipelines.yml: -------------------------------------------------------------------------------- 1 | trigger: none 2 | pr: none 3 | 4 | # Use an internally approved MS host for building, signing, and SBOM generation 5 | pool: 6 | name: '1ES-Hosted-DurableTaskFramework' 7 | demands: 8 | - ImageOverride -equals MMS2022TLS 9 | 10 | steps: 11 | - task: UseDotNet@2 12 | displayName: 'Use the .NET Core 2.1 SDK (required for building signing)' 13 | inputs: 14 | packageType: 'sdk' 15 | version: '2.1.x' 16 | 17 | - task: UseDotNet@2 18 | displayName: 'Use the .NET 6 SDK' 19 | inputs: 20 | packageType: 'sdk' 21 | version: '6.0.x' 22 | 23 | # Start by restoring all the dependencies. This needs to be its own task 24 | # from what I can tell. 25 | - task: DotNetCoreCLI@2 26 | displayName: 'Restore nuget dependencies' 27 | inputs: 28 | command: restore 29 | verbosityRestore: Minimal 30 | projects: '**/*.csproj' 31 | 32 | # Build the entire solution. This will also build all the tests, which 33 | # isn't strictly necessary... 34 | - task: VSBuild@1 35 | displayName: 'Build' 36 | inputs: 37 | solution: '**/*.sln' 38 | vsVersion: 'latest' 39 | logFileVerbosity: minimal 40 | configuration: Release 41 | msbuildArgs: /p:FileVersionRevision=$(Build.BuildId) /p:ContinuousIntegrationBuild=true 42 | 43 | # Authenticode sign all the DLLs with the Microsoft certificate. 44 | # This appears to be an in-place signing job, which is convenient. 45 | - task: SFP.build-tasks.custom-build-task-1.EsrpCodeSigning@1 46 | displayName: 'ESRP CodeSigning: Authenticode' 47 | inputs: 48 | ConnectedServiceName: 'ESRP Service' 49 | FolderPath: 'src' 50 | Pattern: 'DurableTask.*.dll' 51 | signConfigType: inlineSignParams 52 | inlineOperation: | 53 | [ 54 | { 55 | "KeyCode": "CP-230012", 56 | "OperationCode": "SigntoolSign", 57 | "Parameters": { 58 | "OpusName": "Microsoft", 59 | "OpusInfo": "http://www.microsoft.com", 60 | "FileDigest": "/fd \"SHA256\"", 61 | "PageHash": "/NPH", 62 | "TimeStamp": "/tr \"http://rfc3161.gtm.corp.microsoft.com/TSS/HttpTspServer\" /td sha256" 63 | }, 64 | "ToolName": "sign", 65 | "ToolVersion": "1.0" 66 | }, 67 | { 68 | "KeyCode": "CP-230012", 69 | "OperationCode": "SigntoolVerify", 70 | "Parameters": {}, 71 | "ToolName": "sign", 72 | "ToolVersion": "1.0" 73 | } 74 | ] 75 | 76 | # SBOM generator task for additional supply chain protection 77 | - task: AzureArtifacts.manifest-generator-task.manifest-generator-task.ManifestGeneratorTask@0 78 | displayName: 'SBOM Manifest Generator' 79 | inputs: 80 | BuildDropPath: '$(System.DefaultWorkingDirectory)' 81 | 82 | # Packaging needs to be a separate step from build. 83 | # This will automatically pick up the signed DLLs. 84 | - task: DotNetCoreCLI@2 85 | displayName: Generate nuget packages 86 | inputs: 87 | command: pack 88 | verbosityPack: Minimal 89 | configuration: Release 90 | nobuild: true 91 | packDirectory: $(build.artifactStagingDirectory) 92 | packagesToPack: 'src/**/*.csproj' 93 | 94 | # Digitally sign all the nuget packages with the Microsoft certificate. 95 | # This appears to be an in-place signing job, which is convenient. 96 | - task: SFP.build-tasks.custom-build-task-1.EsrpCodeSigning@1 97 | displayName: 'ESRP CodeSigning: Nupkg' 98 | inputs: 99 | ConnectedServiceName: 'ESRP Service' 100 | FolderPath: $(build.artifactStagingDirectory) 101 | Pattern: '*.nupkg' 102 | signConfigType: inlineSignParams 103 | inlineOperation: | 104 | [ 105 | { 106 | "KeyCode": "CP-401405", 107 | "OperationCode": "NuGetSign", 108 | "Parameters": {}, 109 | "ToolName": "sign", 110 | "ToolVersion": "1.0" 111 | }, 112 | { 113 | "KeyCode": "CP-401405", 114 | "OperationCode": "NuGetVerify", 115 | "Parameters": {}, 116 | "ToolName": "sign", 117 | "ToolVersion": "1.0" 118 | } 119 | ] 120 | 121 | # Make the nuget packages available for download in the ADO portal UI 122 | - publish: $(build.artifactStagingDirectory) 123 | displayName: 'Publish nuget packages to Artifacts' 124 | artifact: PackageOutput 125 | -------------------------------------------------------------------------------- /docs/.nojekyll: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/durabletask-mssql/7843abebe1d4c43dfe66253845ee815e57fa05c4/docs/.nojekyll -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Durable Task SQL Provider 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 |
14 | 27 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /docs/introduction.md: -------------------------------------------------------------------------------- 1 | # Introduction 2 | 3 | The Durable Task SQL Provider is a backend for the [Durable Task Framework](https://github.com/Azure/durabletask) (DTFx) and [Azure Durable Functions](https://docs.microsoft.com/azure/azure-functions/durable/durable-functions-overview) that persists all task hub state in a Microsoft SQL database. It's compatible with [on-premises SQL Server](https://www.microsoft.com/sql-server/), [SQL Server for Docker containers](https://hub.docker.com/_/microsoft-mssql-server), the cloud-hosted [Azure SQL Database](https://azure.microsoft.com/services/azure-sql/), and includes support for orchestrations, activities, and durable entities. 4 | 5 | ## Features 6 | 7 | The Microsoft SQL provider is just one of [many supported providers for the Durable Task Framework](https://github.com/Azure/durabletask#supported-persistance-stores). Each backend storage provider has its own strengths and weaknesses. We believe that the Microsoft SQL provider has many strengths that make it worth creating and supporting. 8 | 9 | ### Portability 10 | 11 | Microsoft SQL Server is an industry leading database server available as a managed service or as a standalone installation and is supported by the leading cloud providers ([Azure SQL](https://azure.microsoft.com/services/azure-sql/), [SQL Server on AWS](https://aws.amazon.com/sql/), [Google Cloud SQL](https://cloud.google.com/sql/), etc.). It also is supported on multiple OS platforms, like [Windows Server](https://www.microsoft.com/sql-server/), [Linux Docker containers](https://hub.docker.com/_/microsoft-mssql-server), and more recently on [IoT/Edge](https://azure.microsoft.com/services/sql-edge/) devices. All your orchestration data is contained in a single database that can easily be exported from one host to another, so there is no need to worry about having your data locked to a particular vendor. 12 | 13 | ### Control 14 | 15 | The DTFx schemas can be provisioned into your own SQL database, allowing you to secure it any way you want and incorporate it into existing business continuity processes ([backup/restore](https://docs.microsoft.com/azure/azure-sql/database/automated-backups-overview), [disaster recovery](https://docs.microsoft.com/azure/azure-sql/database/auto-failover-group-overview), etc.). This also means you can easily integrate Durable Functions or DTFx with existing line-of-business applications by co-hosting the data in the same database and leveraging built-in stored procedures to have apps directly interact with orchestrations or entities from SQL. Having control of your database also means that you can scale up or down the corresponding server as needed to meet your price-performance needs - or just let the Azure platform do this automatically with their hosted [Serverless tier](https://docs.microsoft.com/azure/azure-sql/database/serverless-tier-overview). 16 | 17 | ### Simplicity 18 | 19 | This provider was designed from the ground-up with simplicity in mind. The data is transactionally consistent and it's easy to query the tables and views directly using existing tools like the cross-platform [mssql-cli](https://docs.microsoft.com/sql/tools/mssql-cli), [SQL Server Management Studio](https://docs.microsoft.com/sql/ssms), or the [Azure Portal](https://docs.microsoft.com/azure/azure-sql/database/connect-query-portal) if your SQL database is hosted in Azure. Unlike the default Azure Storage provider, the SQL provider backend does not require you to configure partition counts. A single database can support any number of nodes running orchestrations and entities, provided it has sufficient CPU power to handle the required load. You also don't pay any unexpected performance penalties for larger function inputs and outputs. 20 | 21 | ### Multitenancy 22 | 23 | One of the goals for this provider is to create a foundation for safe [multi-tenant deployments](https://en.wikipedia.org/wiki/Multitenancy). This is especially valuable when your organization has many small apps but prefers to manage only a single backend database. Different apps can connect to this database using different database login credentials. Database administrators will be able to query data across all tenants but individual apps will only have access to their own data. When further isolation and security is needed, each app can [have its own schema](multitenancy.md#managing-custom-schemas). Note that tenant-specific code that runs _outside_ the database would still be expected to run on appropriately isolated compute instances. Learn how to get started with multitenancy [here](multitenancy.md). 24 | 25 | ## FAQ 26 | 27 | **Q. Does this require Azure?** 28 | 29 | No. You can run on Azure if you want, but this provider was designed specifically to support running DTFx and Durable Functions in a non-Azure environment. In fact, it's the first production-grade provider that supports non-Azure deployments. 30 | 31 | **Q. When would I choose this over the Azure Storage provider?** 32 | 33 | * If you want to build cloud-agnostic apps - Microsoft SQL databases can be run [anywhere](#portability) 34 | * If you need predictable and performance-scalable pricing 35 | * If you need to scale past the Azure Storage provider's limit of 16 partitions 36 | * If you need enterprise features like data encryption or business continuity features 37 | * If you want direct access to the data - which is supported via SQL views and stored procedures 38 | * If you want multitenancy within a single database 39 | 40 | **Q. Why Microsoft SQL (and not an OSS database, like PostgreSQL)?** 41 | 42 | * Extremely efficient in dealing with large data payloads 43 | * Flexible cloud hosting, including a unique [Azure SQL Serverless tier](https://docs.microsoft.com/azure/azure-sql/) 44 | * Already in use by many organizations, with a proven track record 45 | * Opinionated database support means we can take advantage of native database features 46 | 47 | ## Contact and support 48 | 49 | * Create a [GitHub issue](https://github.com/microsoft/durabletask-mssql/issues) for bug reports, feature requests, or questions. 50 | * Follow [@cgillum](https://twitter.com/cgillum) and [@AzureFunctions](https://twitter.com/AzureFunctions) on Twitter for announcements. 51 | * Add a ⭐️ star on GitHub or ❤️ tweets to support the project! 52 | 53 | ## License 54 | 55 | This project is licensed under the [MIT license](https://github.com/microsoft/durabletask-mssql/blob/main/LICENSE). 56 | 57 | Copyright (c) Microsoft Corporation. 58 | -------------------------------------------------------------------------------- /docs/media/arch-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/durabletask-mssql/7843abebe1d4c43dfe66253845ee815e57fa05c4/docs/media/arch-diagram.png -------------------------------------------------------------------------------- /docs/media/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/durabletask-mssql/7843abebe1d4c43dfe66253845ee815e57fa05c4/docs/media/logo.png -------------------------------------------------------------------------------- /docs/media/schema.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/durabletask-mssql/7843abebe1d4c43dfe66253845ee815e57fa05c4/docs/media/schema.png -------------------------------------------------------------------------------- /docs/media/throughput.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/durabletask-mssql/7843abebe1d4c43dfe66253845ee815e57fa05c4/docs/media/throughput.png -------------------------------------------------------------------------------- /docs/multitenancy.md: -------------------------------------------------------------------------------- 1 | # Multitenancy 2 | 3 | This article describes the multitenancy features of the Durable Task SQL backend and how to enable them. 4 | 5 | ## Overview 6 | 7 | One of the goals for the Microsoft SQL provider for the Durable Task Framework (DTFx) is to enable [multi-tenant deployments](https://en.wikipedia.org/wiki/Multitenancy) with multiple apps sharing the same database. This is often valuable when your organization has many small apps but prefers to manage only a single backend database. When multitenancy is enabled, different apps connect to a shared database using different database login credentials and each app will only have access to its own data. 8 | 9 | Note that there are two modes for multi-tenancy, _shared schema_ mode and _isolated schema_ mode. 10 | 11 | ?> Multitenancy in the current version of the SQL provider prevents one tenant from accessing data that belongs to another tenant. However, it doesn't provide isolation for shared resources within a database, such as memory or CPU. If this kind of strict resource isolation is required, then each tenant should instead be separated into its own database. 12 | 13 | ## Shared schema mode 14 | 15 | Shared schema multitenancy works by isolating each app into a separate [task hub](taskhubs.md). The current task hub is determined by the credentials used to log into the database. For example, if your app connects to a Microsoft SQL database using **dbo** credentials (the default, built-in admin user for most databases), then the name of the connected task hub will be "dbo". Task hubs provide data isolation, ensuring that two users in the same database will not be able to access each other's data. 16 | 17 | Shared schema mode is available starting in the v1.0.0 version of the MSSQL storage provider. The benefit of this mode is that fewer database objects need to be created in the database. It also enables high-privileged user accounts to write SQL queries that span multiple tenants. The downside of this mode is that schema updates must be applied to all tenants at once, which increases the risk associated with schema upgrades. 18 | 19 | ### Enabling shared schema multitenancy 20 | 21 | Shared schema multitenancy is enabled by default. When using shared schema multitenacy, you do not (and should not) configure a task hub name in code or configuration. Instead, the SQL login username (from the [`USER_NAME()`](https://docs.microsoft.com/sql/t-sql/functions/user-name-transact-sql) SQL function) is automatically used as the task hub name (for example, `dbo`). 22 | 23 | The following T-SQL can be used to _disable_ shared schema multitenancy: 24 | 25 | ```sql 26 | -- Disable multi-tenancy mode 27 | EXECUTE dt.SetGlobalSetting @Name='TaskHubMode', @Value=0 28 | ``` 29 | 30 | The value `0` instructs all runtime stored procedures to instead infer the current task hub from the [`APP_NAME()`](https://docs.microsoft.com/sql/t-sql/functions/app-name-transact-sql) SQL function. The configured connection string is automatically modified to ensure that `APP_NAME()` is set to the explicitly configured name of the task hub, or `default` if no task hub name is configured. 31 | 32 | Shared schema multitenancy can be re-enabled using the following T-SQL: 33 | 34 | ```sql 35 | -- Enable multi-tenancy mode 36 | EXECUTE dt.SetGlobalSetting @Name='TaskHubMode', @Value=1 37 | ``` 38 | 39 | !> Enabling or disabling shared schema multitenancy may result in subsequent logins using a different task hub name. Any orchestrations or entities created using a previous task hub names will not be visible to an app that switches to a new task hub name. Switching between task hub modes must therefore be done with careful planning and should not be done while apps are actively running. 40 | 41 | ## Isolated schema mode 42 | 43 | Isolated schema mode provisions an independent set of database objects (tables, views, stored procedures, etc.) for each tenant. This increases reliability and security for multiple services that are independently deployed in the same database, allowing each service control over their own schema and further isolation between service's data. For example, it's also possible to provide a degree of storage isolation for tables of particular tenants using [SQL Server Filegroups](https://docs.microsoft.com/sql/relational-databases/databases/database-files-and-filegroups?view=sql-server-ver16) (note that there is no automatic support for this). Isolated schema mode also allows schema versions to be managed independently for each tenant. 44 | 45 | Isolated schema mode is available starting in the v1.1.0 release of the MSSQL storage provider. 46 | 47 | ### Managing custom schemas 48 | 49 | For Azure Functions apps, you can configure a custom schema name in the Azure Functions **host.json** file. 50 | 51 | ```json 52 | { 53 | "version": "2.0", 54 | "extensions": { 55 | "durableTask": { 56 | "storageProvider": { 57 | "type": "mssql", 58 | "connectionStringName": "SQLDB_Connection", 59 | "schemaName": "MyCustomSchemaName" 60 | } 61 | } 62 | } 63 | } 64 | ``` 65 | 66 | For self-hosted DTFx app that opt for custom schema name, you can configure the schema name directly in the `SqlOrchestrationServiceSettings` constructor. 67 | 68 | ```csharp 69 | var settings = new SqlOrchestrationServiceSettings( 70 | connectionString: Environment.GetEnvironmentVariable("SQLDB_Connection"), 71 | schemaName: "MyCustomSchemaName"); 72 | ``` 73 | 74 | If no schema name name is explicitly configured, the default value `dt` will be used. Note that changing the value requires a restart of the app for the change to take effect. 75 | 76 | ## Managing user credentials 77 | 78 | Once multitenancy is enabled, each tenant must be given its own login and user ID for the target database. To ensure that each tenant can only access its own data, you should add each user to the `{schema_name}_runtime` role that is created automatically by the database setup scripts. By default, this is `dt_runtime` since the default schema name is `dt`. 79 | 80 | The following SQL statements illustrate how this can be done for a SQL database that supports username/password authentication. 81 | 82 | ```sql 83 | -- create the new login credentials 84 | CREATE LOGIN {login_name} WITH PASSWORD = {pw} 85 | GO 86 | 87 | -- create a user account associated with the new login credentials 88 | CREATE USER {username} FOR LOGIN {login_name} 89 | GO 90 | 91 | -- add the user to the restricted dt_runtime role 92 | ALTER ROLE {schema_name}_runtime ADD MEMBER {username} 93 | GO 94 | ``` 95 | 96 | Each tenant should then use a SQL connection string with the above login credentials for their assigned user account. See [this SQL Server documentation](https://docs.microsoft.co/sql/relational-databases/security/authentication-access/create-a-database-user) for more information about how to create and manage database users. 97 | 98 | ?> Task hub names are limited to 50 characters. When multitenancy is enabled, the username is used as the task hub name. If the username exceeds 50 characters, the task hub name value used in the database will be a truncated version of the username followed by an MD5 hash of the full username. 99 | -------------------------------------------------------------------------------- /docs/sidebar.md: -------------------------------------------------------------------------------- 1 | * [Introduction](introduction.md "Durable Task SQL Provider") 2 | * [Getting Started](quickstart.md) 3 | * [Architecture](architecture.md) 4 | * [Scaling](scaling.md) 5 | * [Task Hubs](taskhubs.md) 6 | * [Multitenancy](multitenancy.md) 7 | * [Kubernetes Quickstart](kubernetes.md) -------------------------------------------------------------------------------- /docs/taskhubs.md: -------------------------------------------------------------------------------- 1 | # Task Hubs 2 | 3 | This article describes what task hubs are and how they can be configured. 4 | 5 | ## Overview 6 | 7 | A **task hub** is a logical grouping concept in both the Durable Task Framework (DTFx) and Durable Functions. Orchestrators, activities, and entities all belong to a single task hub and can only interact directly with other orchestrations, activities, and entities that are defined in the same task hub. In the SQL provider, a single database can contain multiple task hubs. Task hub data isolation is enforced at runtime by the underlying DTFx storage provider and its SQL stored procedures. In the case of the DTFx SQL provider, all stored procedures used by the runtime will only ever access data that belongs to the current task hub. 8 | 9 | Task hubs are also the primary unit of isolation within a database. Each table in the Durable Task schema includes a `TaskHub` column as part of its primary key and stored procedures will only access data that belongs to the current _task hub context_. This isolation serves two primary purposes: supporting side-by-side deployments of different application version and [enabling multitenancy](multitenancy.md), as explained in other articles. 10 | 11 | ?> One difference between the Microsoft SQL provider and the Azure Storage provider is that all task hubs in the Microsoft SQL provider share the same tables. In the Azure Storage provider, each task hub is given a completely separate table in Azure Storage (along with isolated queues and blob containers). More importantly, however, is that the SQL provider allows task hubs to be securely isolated from each other. This is not possible with the Azure Storage provider - different tenants would need to be assigned to different storage accounts. The ability for multiple tenants to securely share a SQL databases is therefore much more cost-effective for implementing multitenancy. 12 | 13 | ## Configuring task hub names 14 | 15 | By default, the name of a task hub is the name of the database user. No explicit configuration is required. For more information, see the [Multitenancy](multitenancy.md) topic. 16 | 17 | Automatic task hub name inference can be disabled by disabling multitenancy in the database. When multitenancy is disabled, task hubs names can be configured explicitly in the SQL provider configuration, as shown in the following examples. 18 | 19 | For Durable Functions apps, explicit task hub names are configured in the `extensions/durableTask/hubName` property of the **host.json** file. 20 | 21 | ```json 22 | { 23 | "version": "2.0", 24 | "extensions": { 25 | "durableTask": { 26 | "hubName": "MyTaskHub", 27 | "storageProvider": { 28 | "type": "mssql", 29 | "connectionStringName": "SQLDB_Connection" 30 | } 31 | } 32 | } 33 | } 34 | ``` 35 | 36 | For self-hosted DTFx apps that opt-out of multitenant mode, you can configure the task hub directly in the `SqlOrchestrationServiceSettings` class. 37 | 38 | ```csharp 39 | var settings = new SqlOrchestrationServiceSettings 40 | { 41 | TaskHubName = "MyTaskHub", 42 | TaskHubConnectionString = Environment.GetEnvironmentVariable("SQLDB_Connection"), 43 | }; 44 | ``` 45 | 46 | If no task hub name is explicitly configured, the value `default` will be used. Note that any task hub name configuration is ignored when the database is in multitenancy mode (which is the default behavior). 47 | 48 | ?> Task hub names are limited to 50 characters. If the specified task hub name exceeds 50 characters, it will be truncated and suffixed with an MD5 hash of the full task hub name to keep it within 50 characters. This behavior applies both to task hubs inferred from database usernames and explicitly configured task hub names. 49 | 50 | ## Case sensitivity 51 | 52 | Whether task hub names are case-sensitive depends on the collation of the SQL database. For example, if a [binary collation](https://docs.microsoft.com/sql/relational-databases/collations/collation-and-unicode-support#Binary-collations) is configured on the database, task hub names will be case-sensitive. Non-binary collations may result in case-insensitive string comparisons, making task hub names effectively case-insensitive. For more information on SQL database collations, see [Collation and Unicode support](https://docs.microsoft.com/sql/relational-databases/collations/collation-and-unicode-support) in the Microsoft SQL documentation. 53 | 54 | ?> The preferred database collation for the Durable Task SQL provider is `Latin1_General_100_BIN2_UTF8`, which is a binary collation. 55 | -------------------------------------------------------------------------------- /eng/ci/code-mirror.yml: -------------------------------------------------------------------------------- 1 | trigger: 2 | branches: 3 | include: 4 | # These are the branches we'll mirror to our internal ADO instance 5 | # Keep this set limited as appropriate (don't mirror individual user branches). 6 | - main 7 | 8 | resources: 9 | repositories: 10 | - repository: eng 11 | type: git 12 | name: engineering 13 | ref: refs/tags/release 14 | 15 | variables: 16 | - template: ci/variables/cfs.yml@eng 17 | 18 | extends: 19 | template: ci/code-mirror.yml@eng 20 | -------------------------------------------------------------------------------- /eng/ci/official-build.yml: -------------------------------------------------------------------------------- 1 | variables: 2 | - template: ci/variables/cfs.yml@eng 3 | 4 | trigger: 5 | batch: true 6 | branches: 7 | include: 8 | - main 9 | 10 | # CI only, does not trigger on PRs. 11 | pr: none 12 | 13 | schedules: 14 | # Build nightly to catch any new CVEs and report SDL often. 15 | # We are also required to generated CodeQL reports weekly, so this 16 | # helps us meet that. 17 | - cron: "0 0 * * *" 18 | displayName: Nightly Build 19 | branches: 20 | include: 21 | - main 22 | always: true 23 | 24 | resources: 25 | repositories: 26 | - repository: 1es 27 | type: git 28 | name: 1ESPipelineTemplates/1ESPipelineTemplates 29 | ref: refs/tags/release 30 | - repository: eng 31 | type: git 32 | name: engineering 33 | ref: refs/tags/release 34 | 35 | extends: 36 | template: v1/1ES.Official.PipelineTemplate.yml@1es 37 | parameters: 38 | pool: 39 | name: 1es-pool-azfunc 40 | image: 1es-windows-2022 41 | os: windows 42 | 43 | stages: 44 | - stage: BuildAndSign 45 | dependsOn: [] 46 | jobs: 47 | - template: /eng/templates/build.yml@self -------------------------------------------------------------------------------- /eng/ci/publish.yml: -------------------------------------------------------------------------------- 1 | # This is our package-publishing pipeline. 2 | # When executed, it automatically publishes the output of the 'official pipeline' (the nupkgs) to our internal ADO feed. 3 | # It may optionally also publish the packages to NuGet, but that is gated behind a manual approval. 4 | 5 | trigger: none # only trigger is manual 6 | pr: none # only trigger is manual 7 | 8 | # We include to this variable group to be able to access the NuGet API key 9 | variables: 10 | - group: durabletask_config 11 | 12 | resources: 13 | repositories: 14 | - repository: 1es 15 | type: git 16 | name: 1ESPipelineTemplates/1ESPipelineTemplates 17 | ref: refs/tags/release 18 | - repository: eng 19 | type: git 20 | name: engineering 21 | ref: refs/tags/release 22 | 23 | pipelines: 24 | - pipeline: officialPipeline # Reference to the pipeline to be used as an artifact source 25 | source: 'durabletask-mssql.official' 26 | 27 | extends: 28 | template: v1/1ES.Official.PipelineTemplate.yml@1es 29 | parameters: 30 | pool: 31 | name: 1es-pool-azfunc 32 | image: 1es-windows-2022 33 | os: windows 34 | 35 | stages: 36 | - stage: release 37 | jobs: 38 | 39 | # ADO release 40 | - job: adoRelease 41 | displayName: ADO Release 42 | templateContext: 43 | inputs: 44 | - input: pipelineArtifact 45 | pipeline: officialPipeline # Pipeline reference, as defined in the resources section 46 | artifactName: drop 47 | targetPath: $(System.DefaultWorkingDirectory)/drop 48 | 49 | # The preferred method of release on 1ES is by populating the 'output' section of a 1ES template. 50 | # We use this method to release to ADO, but not to release to NuGet; this is explained in the 'nugetRelease' job. 51 | # To read more about the 'output syntax', see: 52 | # - https://eng.ms/docs/cloud-ai-platform/devdiv/one-engineering-system-1es/1es-docs/1es-pipeline-templates/features/outputs 53 | # - https://eng.ms/docs/cloud-ai-platform/devdiv/one-engineering-system-1es/1es-docs/1es-pipeline-templates/features/outputs/nuget-packages 54 | outputs: 55 | - output: nuget # 'nuget' is an output "type" for pushing to NuGet 56 | displayName: 'Push to durabletask ADO feed' 57 | packageParentPath: $(System.DefaultWorkingDirectory) # This needs to be set to some prefix of the `packagesToPush` parameter. Apparently it helps with SDL tooling 58 | packagesToPush: '$(System.DefaultWorkingDirectory)/**/*.nupkg;!$(System.DefaultWorkingDirectory)/**/*.symbols.nupkg' 59 | publishVstsFeed: '3f99e810-c336-441f-8892-84983093ad7f/c895696b-ce37-4fe7-b7ce-74333a04f8bf' 60 | allowPackageConflicts: true 61 | 62 | # NuGet approval gate 63 | - job: nugetApproval 64 | displayName: NuGetApproval 65 | pool: server # This task only works when executed on serverl pools, so this needs to be specified 66 | steps: 67 | # Wait for manual approval. 68 | - task: ManualValidation@1 69 | inputs: 70 | instructions: Confirm you want to push to NuGet 71 | onTimeout: 'reject' 72 | 73 | # NuGet release 74 | - job: nugetRelease 75 | displayName: NuGet Release 76 | dependsOn: 77 | - nugetApproval 78 | - adoRelease 79 | condition: succeeded('nugetApproval', 'adoRelease') 80 | templateContext: 81 | inputs: 82 | - input: pipelineArtifact 83 | pipeline: officialPipeline # Pipeline reference as defined in the resources section 84 | artifactName: drop 85 | targetPath: $(System.DefaultWorkingDirectory)/drop 86 | # Ideally, we would push to NuGet using the 1ES "template output" syntax, like we do for ADO. 87 | # Unfortunately, that syntax does not allow for skipping duplicates when pushing to NuGet feeds 88 | # (i.e; not failing the job when trying to push a package version that already exists on NuGet). 89 | # This is a problem for us because our pipelines often produce multiple packages, and we want to be able to 90 | # perform a 'nuget push *.nupkg' that skips packages already on NuGet while pushing the rest. 91 | # Therefore, we use a regular .NET Core ADO Task to publish the packages until that usability gap is addressed. 92 | steps: 93 | - task: DotNetCoreCLI@2 94 | displayName: 'Push to nuget.org' 95 | inputs: 96 | command: custom 97 | custom: nuget 98 | arguments: 'push "*.nupkg" --api-key $(nuget_api_key) --skip-duplicate --source https://api.nuget.org/v3/index.json' 99 | workingDirectory: '$(System.DefaultWorkingDirectory)/drop' -------------------------------------------------------------------------------- /eng/templates/build.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | - job: Build 3 | 4 | templateContext: 5 | outputs: 6 | - output: pipelineArtifact 7 | path: $(build.artifactStagingDirectory) 8 | artifact: drop 9 | sbomBuildDropPath: $(System.DefaultWorkingDirectory) 10 | sbomPackageName: 'DurableTask-MSSQL SBOM' 11 | 12 | steps: 13 | - task: UseDotNet@2 14 | displayName: 'Use the .NET 6 SDK' 15 | inputs: 16 | packageType: 'sdk' 17 | version: '6.0.x' 18 | 19 | # Start by restoring all the dependencies. This needs to be its own task 20 | # from what I can tell. 21 | - task: DotNetCoreCLI@2 22 | displayName: 'Restore nuget dependencies' 23 | inputs: 24 | command: restore 25 | verbosityRestore: Minimal 26 | projects: '**/*.csproj' 27 | 28 | # Build the entire solution. This will also build all the tests, which 29 | # isn't strictly necessary... 30 | - task: VSBuild@1 31 | displayName: 'Build' 32 | inputs: 33 | solution: '**/*.sln' 34 | vsVersion: 'latest' 35 | logFileVerbosity: minimal 36 | configuration: Release 37 | msbuildArgs: /p:FileVersionRevision=$(Build.BuildId) /p:ContinuousIntegrationBuild=true 38 | 39 | - template: ci/sign-files.yml@eng 40 | parameters: 41 | displayName: Sign assemblies 42 | folderPath: src 43 | pattern: DurableTask.*.dll 44 | signType: dll 45 | 46 | # Packaging needs to be a separate step from build. 47 | # This will automatically pick up the signed DLLs. 48 | - task: DotNetCoreCLI@2 49 | displayName: Generate nuget packages 50 | inputs: 51 | command: pack 52 | verbosityPack: Minimal 53 | configuration: Release 54 | nobuild: true 55 | packDirectory: $(build.artifactStagingDirectory) 56 | packagesToPack: 'src/**/*.csproj' 57 | 58 | - template: ci/sign-files.yml@eng 59 | parameters: 60 | displayName: Sign NugetPackages 61 | folderPath: $(build.artifactStagingDirectory) 62 | pattern: '*.nupkg' 63 | signType: nuget -------------------------------------------------------------------------------- /nuget.config: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /sign.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/durabletask-mssql/7843abebe1d4c43dfe66253845ee815e57fa05c4/sign.snk -------------------------------------------------------------------------------- /src/DurableTask.SqlServer.AzureFunctions/DurableTask.SqlServer.AzureFunctions.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | net6.0 8 | 9 | 10 | 11 | $(DefineConstants);FUNCTIONS_V4 12 | 13 | 14 | 15 | 16 | Microsoft.DurableTask.SqlServer.AzureFunctions 17 | Azure Durable Functions SQL Provider 18 | Microsoft SQL provider for Azure Durable Functions. 19 | Microsoft;Azure;Functions;Durable;Task;Orchestration;Workflow;Activity;Reliable;SQL 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.AzureFunctions 5 | { 6 | using System; 7 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 8 | using Microsoft.Data.SqlClient; 9 | using Microsoft.Extensions.Configuration; 10 | using Microsoft.Extensions.Logging; 11 | using Microsoft.Extensions.Logging.Abstractions; 12 | using Newtonsoft.Json; 13 | 14 | class SqlDurabilityOptions 15 | { 16 | [JsonProperty("connectionStringName")] 17 | public string ConnectionStringName { get; set; } = "SQLDB_Connection"; 18 | 19 | [JsonProperty("taskHubName")] 20 | public string TaskHubName { get; set; } = "default"; 21 | 22 | [JsonProperty("taskEventLockTimeout")] 23 | public TimeSpan TaskEventLockTimeout { get; set; } = TimeSpan.FromMinutes(2); 24 | 25 | [JsonProperty("taskEventBatchSize")] 26 | public int TaskEventBatchSize { get; set; } = 10; 27 | 28 | [JsonProperty("createDatabaseIfNotExists")] 29 | public bool CreateDatabaseIfNotExists { get; set; } 30 | 31 | [JsonProperty("schemaName")] 32 | public string? SchemaName { get; set; } 33 | 34 | internal ILoggerFactory LoggerFactory { get; set; } = NullLoggerFactory.Instance; 35 | 36 | internal SqlOrchestrationServiceSettings GetOrchestrationServiceSettings( 37 | DurableTaskOptions extensionOptions, 38 | IConnectionInfoResolver connectionStringResolver) 39 | { 40 | if (connectionStringResolver == null) 41 | { 42 | throw new ArgumentNullException(nameof(connectionStringResolver)); 43 | } 44 | 45 | IConfigurationSection connectionStringSection = connectionStringResolver.Resolve(this.ConnectionStringName); 46 | if (connectionStringSection == null || string.IsNullOrEmpty(connectionStringSection.Value)) 47 | { 48 | throw new InvalidOperationException( 49 | $"No SQL connection string configuration was found for the app setting or environment variable named '{this.ConnectionStringName}'."); 50 | } 51 | 52 | // Validate the connection string 53 | try 54 | { 55 | new SqlConnectionStringBuilder(connectionStringSection.Value); 56 | } 57 | catch (ArgumentException e) 58 | { 59 | throw new ArgumentException("The provided connection string is invalid.", e); 60 | } 61 | 62 | var settings = new SqlOrchestrationServiceSettings(connectionStringSection.Value, this.TaskHubName, this.SchemaName) 63 | { 64 | CreateDatabaseIfNotExists = this.CreateDatabaseIfNotExists, 65 | LoggerFactory = this.LoggerFactory, 66 | WorkItemBatchSize = this.TaskEventBatchSize, 67 | WorkItemLockTimeout = this.TaskEventLockTimeout, 68 | }; 69 | 70 | if (extensionOptions.MaxConcurrentActivityFunctions.HasValue) 71 | { 72 | settings.MaxConcurrentActivities = extensionOptions.MaxConcurrentActivityFunctions.Value; 73 | } 74 | 75 | if (extensionOptions.MaxConcurrentOrchestratorFunctions.HasValue) 76 | { 77 | settings.MaxActiveOrchestrations = extensionOptions.MaxConcurrentOrchestratorFunctions.Value; 78 | } 79 | 80 | return settings; 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityProviderExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.AzureFunctions 5 | { 6 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | /// 10 | /// Extension methods for the Microsoft SQL Durable Task storage provider. 11 | /// 12 | public static class SqlDurabilityProviderExtensions 13 | { 14 | /// 15 | /// Adds Durable Task SQL storage provider services to the specified . 16 | /// 17 | /// The for adding services. 18 | public static void AddDurableTaskSqlProvider(this IServiceCollection services) 19 | { 20 | services.AddSingleton(); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityProviderFactory.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.AzureFunctions 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 9 | using Microsoft.Extensions.Logging; 10 | using Microsoft.Extensions.Options; 11 | using Newtonsoft.Json; 12 | 13 | /// 14 | /// Microsoft SQL implementation for Durable Tasks in Azure Functions. 15 | /// 16 | class SqlDurabilityProviderFactory : IDurabilityProviderFactory 17 | { 18 | readonly Dictionary clientProviders = 19 | new Dictionary(StringComparer.OrdinalIgnoreCase); 20 | 21 | readonly DurableTaskOptions extensionOptions; 22 | readonly ILoggerFactory loggerFactory; 23 | readonly IConnectionInfoResolver connectionInfoResolver; 24 | 25 | SqlDurabilityOptions? defaultOptions; 26 | SqlDurabilityProvider? defaultProvider; 27 | 28 | /// 29 | /// Initializes a new instance of the class. 30 | /// 31 | /// 32 | /// Intended to be called by the Azure Functions runtime dependency injection infrastructure. 33 | /// 34 | /// Durable task extension configuration options. 35 | /// Logger factory registered with the Azure Functions runtime. 36 | /// Resolver service for fetching Durable Task connection string information. 37 | public SqlDurabilityProviderFactory( 38 | IOptions extensionOptions, 39 | ILoggerFactory loggerFactory, 40 | IConnectionInfoResolver connectionInfoResolver) 41 | { 42 | this.extensionOptions = extensionOptions?.Value ?? throw new ArgumentNullException(nameof(extensionOptions)); 43 | this.loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory)); 44 | this.connectionInfoResolver = connectionInfoResolver ?? throw new ArgumentNullException(nameof(connectionInfoResolver)); 45 | } 46 | 47 | // Called by the Durable trigger binding infrastructure 48 | public string Name => SqlDurabilityProvider.Name; 49 | 50 | // Called by the Durable trigger binding infrastructure 51 | public DurabilityProvider GetDurabilityProvider() 52 | { 53 | if (this.defaultProvider == null) 54 | { 55 | SqlDurabilityOptions sqlProviderOptions = this.GetDefaultSqlOptions(); 56 | SqlOrchestrationService service = this.GetOrchestrationService(sqlProviderOptions); 57 | this.defaultProvider = new SqlDurabilityProvider(service, sqlProviderOptions); 58 | } 59 | 60 | return this.defaultProvider; 61 | } 62 | 63 | // Called by the Durable client binding infrastructure 64 | public DurabilityProvider GetDurabilityProvider(DurableClientAttribute attribute) 65 | { 66 | lock (this.clientProviders) 67 | { 68 | string key = GetDurabilityProviderKey(attribute); 69 | if (this.clientProviders.TryGetValue(key, out DurabilityProvider? clientProvider)) 70 | { 71 | return clientProvider; 72 | } 73 | 74 | SqlDurabilityOptions clientOptions = this.GetSqlOptions(attribute); 75 | SqlOrchestrationService orchestrationService = 76 | this.GetOrchestrationService(clientOptions); 77 | clientProvider = new SqlDurabilityProvider( 78 | orchestrationService, 79 | clientOptions); 80 | 81 | this.clientProviders.Add(key, clientProvider); 82 | return clientProvider; 83 | } 84 | } 85 | 86 | SqlOrchestrationService GetOrchestrationService(SqlDurabilityOptions clientOptions) 87 | { 88 | return new (clientOptions.GetOrchestrationServiceSettings( 89 | this.extensionOptions, 90 | this.connectionInfoResolver)); 91 | } 92 | 93 | static string GetDurabilityProviderKey(DurableClientAttribute attribute) 94 | { 95 | return attribute.ConnectionName + "|" + attribute.TaskHub; 96 | } 97 | 98 | SqlDurabilityOptions GetDefaultSqlOptions() 99 | { 100 | if (this.defaultOptions == null) 101 | { 102 | this.defaultOptions = this.GetSqlOptions(new DurableClientAttribute()); 103 | } 104 | 105 | return this.defaultOptions; 106 | } 107 | 108 | SqlDurabilityOptions GetSqlOptions(DurableClientAttribute attribute) 109 | { 110 | var options = new SqlDurabilityOptions 111 | { 112 | TaskHubName = this.extensionOptions.HubName, 113 | LoggerFactory = this.loggerFactory, 114 | }; 115 | 116 | // Deserialize the configuration directly from the host.json settings. 117 | // Note that not all settings can be applied from JSON. 118 | string configJson = JsonConvert.SerializeObject(this.extensionOptions.StorageProvider); 119 | JsonConvert.PopulateObject(configJson, options); 120 | 121 | // Attribute properties can override host.json settings. 122 | if (!string.IsNullOrEmpty(attribute.ConnectionName)) 123 | { 124 | options.ConnectionStringName = attribute.ConnectionName; 125 | } 126 | 127 | if (!string.IsNullOrEmpty(attribute.TaskHub)) 128 | { 129 | options.TaskHubName = attribute.TaskHub; 130 | } 131 | 132 | return options; 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer.AzureFunctions/SqlDurabilityProviderStartup.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | [assembly: Microsoft.Azure.WebJobs.Hosting.WebJobsStartup( 5 | typeof(DurableTask.SqlServer.AzureFunctions.SqlDurabilityProviderStartup))] 6 | 7 | namespace DurableTask.SqlServer.AzureFunctions 8 | { 9 | using Microsoft.Azure.WebJobs; 10 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 11 | using Microsoft.Azure.WebJobs.Hosting; 12 | using Microsoft.Extensions.DependencyInjection; 13 | 14 | 15 | class SqlDurabilityProviderStartup : IWebJobsStartup 16 | { 17 | public void Configure(IWebJobsBuilder builder) 18 | { 19 | builder.Services.AddSingleton(); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer.AzureFunctions/SqlMetricsProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.AzureFunctions 5 | { 6 | using System; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | 10 | public class SqlMetricsProvider 11 | { 12 | readonly SqlOrchestrationService service; 13 | DateTime metricsTimeStamp = DateTime.MinValue; 14 | SqlScaleMetric? metrics; 15 | 16 | public SqlMetricsProvider(SqlOrchestrationService service) 17 | { 18 | this.service = service; 19 | } 20 | 21 | public virtual async Task GetMetricsAsync(int? previousWorkerCount = null) 22 | { 23 | // We only want to query the metrics every 5 seconds. 24 | if (this.metrics == null || DateTime.UtcNow >= this.metricsTimeStamp.AddSeconds(5)) 25 | { 26 | // GetRecommendedReplicaCountAsync will write a trace if the recommendation results 27 | // in a worker count that is different from the worker count we pass in as an argument. 28 | int recommendedReplicaCount = await this.service.GetRecommendedReplicaCountAsync( 29 | previousWorkerCount, 30 | CancellationToken.None); 31 | 32 | this.metricsTimeStamp = DateTime.UtcNow; 33 | this.metrics = new SqlScaleMetric { RecommendedReplicaCount = recommendedReplicaCount }; 34 | } 35 | 36 | return this.metrics; 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer.AzureFunctions/SqlScaleMetric.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.AzureFunctions 5 | { 6 | using Microsoft.Azure.WebJobs.Host.Scale; 7 | 8 | public class SqlScaleMetric : ScaleMetrics 9 | { 10 | public int RecommendedReplicaCount { get; set; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer.AzureFunctions/SqlScaleMonitor.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.AzureFunctions 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | using Microsoft.Azure.WebJobs.Host.Scale; 12 | 13 | /// 14 | /// Azure Functions scale monitor implementation for the Durable Functions SQL backend. 15 | /// 16 | class SqlScaleMonitor : IScaleMonitor 17 | { 18 | static readonly ScaleStatus ScaleInVote = new ScaleStatus { Vote = ScaleVote.ScaleIn }; 19 | static readonly ScaleStatus NoScaleVote = new ScaleStatus { Vote = ScaleVote.None }; 20 | static readonly ScaleStatus ScaleOutVote = new ScaleStatus { Vote = ScaleVote.ScaleOut }; 21 | 22 | readonly SqlMetricsProvider metricsProvider; 23 | 24 | int? previousWorkerCount = -1; 25 | 26 | public SqlScaleMonitor(string functionId, string taskHubName, SqlMetricsProvider sqlMetricsProvider) 27 | { 28 | // Scalers in Durable Functions is per function ids. And scalers share the same sqlMetricsProvider in the same taskhub. 29 | string id = $"{functionId}-DurableTask-SqlServer:{taskHubName ?? "default"}"; 30 | 31 | #if FUNCTIONS_V4 32 | this.Descriptor = new ScaleMonitorDescriptor(id: id, functionId: functionId); 33 | #else 34 | this.Descriptor = new ScaleMonitorDescriptor(id); 35 | #endif 36 | this.metricsProvider = sqlMetricsProvider ?? throw new ArgumentNullException(nameof(sqlMetricsProvider)); 37 | } 38 | 39 | /// 40 | public ScaleMonitorDescriptor Descriptor { get; } 41 | 42 | /// 43 | async Task IScaleMonitor.GetMetricsAsync() => await this.GetMetricsAsync(); 44 | 45 | /// 46 | public async Task GetMetricsAsync() 47 | { 48 | return await this.metricsProvider.GetMetricsAsync(this.previousWorkerCount); 49 | } 50 | 51 | /// 52 | ScaleStatus IScaleMonitor.GetScaleStatus(ScaleStatusContext context) => 53 | this.GetScaleStatusCore(context.WorkerCount, context.Metrics.Cast()); 54 | 55 | /// 56 | public ScaleStatus GetScaleStatus(ScaleStatusContext context) => 57 | this.GetScaleStatusCore(context.WorkerCount, context.Metrics); 58 | 59 | ScaleStatus GetScaleStatusCore(int currentWorkerCount, IEnumerable metrics) 60 | { 61 | SqlScaleMetric? mostRecentMetric = metrics.LastOrDefault(); 62 | if (mostRecentMetric == null) 63 | { 64 | return NoScaleVote; 65 | } 66 | 67 | this.previousWorkerCount = currentWorkerCount; 68 | 69 | if (mostRecentMetric.RecommendedReplicaCount > currentWorkerCount) 70 | { 71 | return ScaleOutVote; 72 | } 73 | else if (mostRecentMetric.RecommendedReplicaCount < currentWorkerCount) 74 | { 75 | return ScaleInVote; 76 | } 77 | else 78 | { 79 | return NoScaleVote; 80 | } 81 | } 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer.AzureFunctions/SqlTargetScaler.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | #if FUNCTIONS_V4 5 | namespace DurableTask.SqlServer.AzureFunctions 6 | { 7 | using System; 8 | using System.Threading.Tasks; 9 | using Microsoft.Azure.WebJobs.Host.Scale; 10 | 11 | public class SqlTargetScaler : ITargetScaler 12 | { 13 | readonly SqlMetricsProvider sqlMetricsProvider; 14 | 15 | public SqlTargetScaler(string functionId, SqlMetricsProvider sqlMetricsProvider) 16 | { 17 | this.sqlMetricsProvider = sqlMetricsProvider; 18 | 19 | // Scalers in Durable Functions is per function ids. And scalers share the same sqlMetricsProvider in the same taskhub. 20 | this.TargetScalerDescriptor = new TargetScalerDescriptor(functionId); 21 | } 22 | 23 | public TargetScalerDescriptor TargetScalerDescriptor { get; } 24 | 25 | public async Task GetScaleResultAsync(TargetScalerContext context) 26 | { 27 | SqlScaleMetric sqlScaleMetric = await this.sqlMetricsProvider.GetMetricsAsync(); 28 | return new TargetScalerResult 29 | { 30 | TargetWorkerCount = Math.Max(0, sqlScaleMetric.RecommendedReplicaCount), 31 | }; 32 | } 33 | } 34 | } 35 | #endif 36 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | using System.Runtime.CompilerServices; 5 | 6 | [assembly: InternalsVisibleTo("DurableTask.SqlServer.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100fd8328dce03cd2e3033a411da400c391864fb4896f1265b2e46914ae677f9268e57ce00fe5ab144bf1746670c16798821c1e821dc3bc0ebce8374c20de809e7ae1b613b71a0a2a5680782e0458cec6c520bc77a90b2c5b00425da400b611d110a43219a9db52e89ce52705e8d11e68ca536f9d5dbe1de8c054d4f70161984de3")] -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/BackoffPollingHelper.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer 5 | { 6 | using System; 7 | using System.Threading; 8 | using System.Threading.Tasks; 9 | 10 | /// 11 | /// Utility for implementing semi-intelligent backoff polling for Azure queues. 12 | /// 13 | class BackoffPollingHelper 14 | { 15 | readonly RandomizedExponentialBackoffStrategy backoffStrategy; 16 | readonly AsyncAutoResetEvent resetEvent; 17 | 18 | public BackoffPollingHelper(TimeSpan minimumInterval, TimeSpan maximumInterval, TimeSpan deltaBackoff) 19 | { 20 | this.backoffStrategy = new RandomizedExponentialBackoffStrategy(minimumInterval, maximumInterval, deltaBackoff); 21 | this.resetEvent = new AsyncAutoResetEvent(signaled: false); 22 | } 23 | 24 | public void Reset() 25 | { 26 | this.resetEvent.Set(); 27 | } 28 | 29 | public async Task WaitAsync(CancellationToken hostCancellationToken) 30 | { 31 | bool signaled = await this.resetEvent.WaitAsync(this.backoffStrategy.CurrentInterval, hostCancellationToken); 32 | this.backoffStrategy.UpdateDelay(signaled); 33 | return signaled; 34 | } 35 | 36 | // Borrowed from https://raw.githubusercontent.com/Azure/azure-webjobs-sdk/b798412ad74ba97cf2d85487ae8479f277bdd85c/src/Microsoft.Azure.WebJobs.Host/Timers/RandomizedExponentialBackoffStrategy.cs 37 | class RandomizedExponentialBackoffStrategy 38 | { 39 | public const double RandomizationFactor = 0.2; 40 | 41 | readonly TimeSpan minimumInterval; 42 | readonly TimeSpan maximumInterval; 43 | readonly TimeSpan deltaBackoff; 44 | 45 | readonly Random random; 46 | 47 | uint backoffExponent; 48 | 49 | public RandomizedExponentialBackoffStrategy( 50 | TimeSpan minimumInterval, 51 | TimeSpan maximumInterval, 52 | TimeSpan deltaBackoff) 53 | { 54 | if (minimumInterval.Ticks < 0) 55 | { 56 | throw new ArgumentOutOfRangeException(nameof(minimumInterval), "The TimeSpan must not be negative."); 57 | } 58 | 59 | if (maximumInterval.Ticks < 0) 60 | { 61 | throw new ArgumentOutOfRangeException(nameof(maximumInterval), "The TimeSpan must not be negative."); 62 | } 63 | 64 | if (minimumInterval.Ticks > maximumInterval.Ticks) 65 | { 66 | throw new ArgumentException( 67 | $"The {nameof(minimumInterval)} must not be greater than the {nameof(maximumInterval)}.", 68 | nameof(minimumInterval)); 69 | } 70 | 71 | this.random = new Random(); 72 | 73 | this.minimumInterval = minimumInterval; 74 | this.maximumInterval = maximumInterval; 75 | this.deltaBackoff = deltaBackoff; 76 | } 77 | 78 | public TimeSpan CurrentInterval { get; private set; } 79 | 80 | public TimeSpan UpdateDelay(bool executionSucceeded) 81 | { 82 | if (executionSucceeded) 83 | { 84 | this.CurrentInterval = this.minimumInterval; 85 | this.backoffExponent = 1; 86 | } 87 | else if (this.CurrentInterval != this.maximumInterval) 88 | { 89 | TimeSpan backoffInterval = this.minimumInterval; 90 | 91 | if (this.backoffExponent > 0) 92 | { 93 | double incrementMsec = 94 | this.GetRandomDouble(1.0 - RandomizationFactor, 1.0 + RandomizationFactor) * 95 | Math.Pow(2.0, this.backoffExponent - 1) * this.deltaBackoff.TotalMilliseconds; 96 | backoffInterval += TimeSpan.FromMilliseconds(incrementMsec); 97 | } 98 | 99 | if (backoffInterval < this.maximumInterval) 100 | { 101 | this.CurrentInterval = backoffInterval; 102 | this.backoffExponent++; 103 | } 104 | else 105 | { 106 | this.CurrentInterval = this.maximumInterval; 107 | } 108 | } 109 | 110 | // else do nothing and keep current interval equal to max 111 | return this.CurrentInterval; 112 | } 113 | 114 | double GetRandomDouble(double minValue, double maxValue) 115 | { 116 | return this.random.NextDouble() * (maxValue - minValue) + minValue; 117 | } 118 | } 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/DbTaskEvent.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer 5 | { 6 | using System; 7 | using DurableTask.Core; 8 | 9 | readonly struct DbTaskEvent 10 | { 11 | readonly DateTime timestamp; 12 | 13 | public DbTaskEvent(TaskMessage message, int taskId, string? lockedBy = null, DateTime? lockExpiration = null) 14 | { 15 | this.Message = message ?? throw new ArgumentNullException(nameof(message)); 16 | this.TaskId = taskId; 17 | this.LockedBy = lockedBy; 18 | this.LockExpiration = lockExpiration; 19 | this.timestamp = DateTime.Now; 20 | } 21 | 22 | public TaskMessage Message { get; } 23 | 24 | public int TaskId { get; } 25 | 26 | public string? LockedBy { get; } 27 | 28 | public DateTime? LockExpiration { get; } 29 | 30 | public bool IsLocal => this.LockedBy != null; 31 | 32 | public TimeSpan GetAge() => DateTime.Now.Subtract(this.timestamp); 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/DurableTask.SqlServer.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | netstandard2.0 8 | true 9 | 10 | 11 | 12 | 13 | Microsoft.DurableTask.SqlServer 14 | Durable Task SQL Provider 15 | Microsoft SQL service provider for the Durable Task Framework. 16 | Microsoft;Durable;Task;Orchestration;Workflow;Activity;Reliable;SQL 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/EventPayloadMap.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using DurableTask.Core; 9 | using DurableTask.Core.History; 10 | 11 | class EventPayloadMap 12 | { 13 | readonly Dictionary<(EventType, int), Guid> payloadIdsByEventId; 14 | readonly Dictionary payloadIdsByEventReference; 15 | 16 | readonly byte[] timestamp = BitConverter.GetBytes(DateTime.UtcNow.Ticks); 17 | short sequenceNumber; 18 | 19 | public EventPayloadMap(int capacity) 20 | { 21 | this.payloadIdsByEventId = new Dictionary<(EventType, int), Guid>(capacity); 22 | this.payloadIdsByEventReference = new Dictionary(capacity); 23 | } 24 | 25 | public void Add(HistoryEvent e, Guid payloadId) 26 | { 27 | if (CanTrackByReference(e)) 28 | { 29 | this.payloadIdsByEventReference.Add(e, payloadId); 30 | } 31 | else 32 | { 33 | var key = ValueTuple.Create(e.EventType, DTUtils.GetTaskEventId(e)); 34 | this.payloadIdsByEventId.Add(key, payloadId); 35 | } 36 | } 37 | 38 | public void Add(IList outboundMessages) 39 | { 40 | for (int i = 0; i < outboundMessages.Count; i++) 41 | { 42 | HistoryEvent e = outboundMessages[i].Event; 43 | if (DTUtils.HasPayload(e)) 44 | { 45 | this.Add(e, this.NewPayloadId(e)); 46 | } 47 | } 48 | } 49 | 50 | public bool TryGetPayloadId(HistoryEvent e, out Guid payloadId) 51 | { 52 | if (CanTrackByReference(e)) 53 | { 54 | return this.payloadIdsByEventReference.TryGetValue(e, out payloadId); 55 | } 56 | else 57 | { 58 | var key = ValueTuple.Create(e.EventType, DTUtils.GetTaskEventId(e)); 59 | return this.payloadIdsByEventId.TryGetValue(key, out payloadId); 60 | } 61 | } 62 | 63 | static bool CanTrackByReference(HistoryEvent e) 64 | { 65 | // DTFx sometimes creates different object references between messages and history events, which 66 | // means we have to use some other mechanism for tracking. 67 | return e.EventType != EventType.TaskScheduled && e.EventType != EventType.SubOrchestrationInstanceCreated; 68 | } 69 | 70 | Guid NewPayloadId(HistoryEvent e) 71 | { 72 | // Sequential GUIDs are simply to make reading slightly easier. They don't have any other purpose. 73 | // Example: 00000001-0004-0000-ca2b-694a052ada08 74 | return new Guid(DTUtils.GetTaskEventId(e), (short)e.EventType, this.sequenceNumber++, this.timestamp); 75 | } 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/LogHelper.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer 5 | { 6 | using System; 7 | using System.Diagnostics; 8 | using DurableTask.Core; 9 | using DurableTask.Core.Logging; 10 | using DurableTask.SqlServer.Logging; 11 | using Microsoft.Extensions.Logging; 12 | 13 | class LogHelper 14 | { 15 | readonly ILogger log; 16 | 17 | public LogHelper(ILogger log) 18 | { 19 | this.log = log ?? throw new ArgumentNullException(nameof(log)); 20 | } 21 | 22 | public void ExecutedSqlScript(string name, Stopwatch latencyStopwatch) 23 | { 24 | var logEvent = new LogEvents.ExecutedSqlScriptEvent( 25 | name, 26 | latencyStopwatch.ElapsedMilliseconds); 27 | 28 | this.WriteLog(logEvent); 29 | } 30 | 31 | public void SprocCompleted(string name, Stopwatch latencyStopwatch, int retryCount, string? instanceId) 32 | { 33 | var logEvent = new LogEvents.SprocCompletedEvent( 34 | name, 35 | latencyStopwatch.ElapsedMilliseconds, 36 | retryCount, 37 | instanceId); 38 | 39 | this.WriteLog(logEvent); 40 | } 41 | 42 | public void AcquiredAppLock(int statusCode, Stopwatch latencyStopwatch) 43 | { 44 | var logEvent = new LogEvents.AcquiredAppLockEvent( 45 | statusCode, 46 | latencyStopwatch.ElapsedMilliseconds); 47 | 48 | this.WriteLog(logEvent); 49 | } 50 | 51 | public void ProcessingError(Exception e, OrchestrationInstance instance) 52 | { 53 | var logEvent = new LogEvents.ProcessingErrorEvent(e, instance); 54 | this.WriteLog(logEvent); 55 | } 56 | 57 | public void GenericWarning(string details, string? instanceId) 58 | { 59 | var logEvent = new LogEvents.GenericWarning(details, instanceId); 60 | this.WriteLog(logEvent); 61 | } 62 | 63 | public void CheckpointStarting(OrchestrationState state) 64 | { 65 | var logEvent = new LogEvents.CheckpointStartingEvent( 66 | state.Name, 67 | state.OrchestrationInstance, 68 | state.OrchestrationStatus); 69 | 70 | this.WriteLog(logEvent); 71 | } 72 | 73 | public void CheckpointCompleted(OrchestrationState state, Stopwatch latencyStopwatch) 74 | { 75 | var logEvent = new LogEvents.CheckpointCompletedEvent( 76 | state.Name, 77 | state.OrchestrationInstance, 78 | latencyStopwatch.ElapsedMilliseconds); 79 | 80 | this.WriteLog(logEvent); 81 | } 82 | 83 | public void DuplicateExecutionDetected(OrchestrationInstance instance, string name) 84 | { 85 | var logEvent = new LogEvents.DuplicateExecutionDetected( 86 | instance.InstanceId, 87 | instance.ExecutionId, 88 | name); 89 | this.WriteLog(logEvent); 90 | } 91 | 92 | public void TransientDatabaseFailure(Exception e, string? instanceId, int retryCount) 93 | { 94 | var logEvent = new LogEvents.TransientDatabaseFailure(e, instanceId, retryCount); 95 | this.WriteLog(logEvent); 96 | } 97 | 98 | public void ReplicaCountChangeRecommended(int currentCount, int recommendedCount) 99 | { 100 | var logEvent = new LogEvents.ReplicaCountChangeRecommended( 101 | currentCount, 102 | recommendedCount); 103 | this.WriteLog(logEvent); 104 | } 105 | 106 | public void PurgedInstances(string userId, int purgedInstanceCount, Stopwatch latencyStopwatch) 107 | { 108 | var logEvent = new LogEvents.PurgedInstances( 109 | userId, 110 | purgedInstanceCount, 111 | latencyStopwatch.ElapsedMilliseconds); 112 | this.WriteLog(logEvent); 113 | } 114 | 115 | public void CommandCompleted(string commandText, Stopwatch latencyStopwatch, int retryCount, string? instanceId) 116 | { 117 | var logEvent = new LogEvents.CommandCompletedEvent( 118 | commandText, 119 | latencyStopwatch.ElapsedMilliseconds, 120 | retryCount, 121 | instanceId); 122 | 123 | this.WriteLog(logEvent); 124 | } 125 | 126 | public void CreatedDatabase(string databaseName) 127 | { 128 | var logEvent = new LogEvents.CreatedDatabaseEvent(databaseName); 129 | this.WriteLog(logEvent); 130 | } 131 | 132 | public void DiscardingEvent(string instanceId, string eventType, int taskEventId, string details) 133 | { 134 | var logEvent = new LogEvents.DiscardingEventEvent( 135 | instanceId, 136 | eventType, 137 | taskEventId, 138 | details); 139 | this.WriteLog(logEvent); 140 | } 141 | 142 | public void GenericInfoEvent(string details, string? instanceId) 143 | { 144 | var logEvent = new LogEvents.GenericInfo(details, instanceId); 145 | this.WriteLog(logEvent); 146 | } 147 | 148 | void WriteLog(ILogEvent logEvent) 149 | { 150 | // LogDurableEvent is an extension method defined in DurableTask.Core 151 | this.log.LogDurableEvent(logEvent); 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/Logging/EventIds.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.Logging 5 | { 6 | /// 7 | /// List of logging event IDs supported by this provider. 8 | /// 9 | static class EventIds 10 | { 11 | // WARNING: Changing the *name* OR the *value* of any of these constants is a breaking change!! 12 | public const int AcquiredAppLock = 300; 13 | public const int ExecutedSqlScript = 301; 14 | public const int SprocCompleted = 302; 15 | public const int ProcessingFailure = 303; 16 | public const int GenericWarning = 304; 17 | public const int CheckpointStarting = 305; 18 | public const int CheckpointCompleted = 306; 19 | public const int DuplicateExecutionDetected = 307; 20 | public const int TransientDatabaseFailure = 308; 21 | public const int ReplicaCountChangeRecommended = 309; 22 | public const int PurgedInstances = 310; 23 | public const int CommandCompleted = 311; 24 | public const int CreatedDatabase = 312; 25 | public const int DiscardingEvent = 313; 26 | public const int GenericInfo = 314; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/Scripts/README.md: -------------------------------------------------------------------------------- 1 | 2 | # Database Schema Scripts 3 | 4 | This README describes the database schema scripts used by the Durable Task MSSQL storage provider and how to make changes to the database schema. 5 | 6 | ## Overview 7 | 8 | This directory contains the scripts used to create and update the database schema for the Durable Task MSSQL storage provider. 9 | The following scripts are provided: 10 | 11 | * `schema-x.y.z.sql`: Creates or updates the database table schema objects. 12 | * `logic.sql`: Creates or updates the database stored procedures. 13 | * `permissions.sql`: Grants permissions to the Durable Task extension to access the schema objects. 14 | * `drop-schema.sql`: Drops the Durable Task schema and all objects in it from the database. 15 | 16 | These script files are embedded directly into the `DurableTask.SqlServer.dll` assembly and are executed as necessary to either create 17 | the MSSQL database schema or upgrade it to the latest version. 18 | 19 | ## Schema Versioning 20 | 21 | The database schema is versioned using the [Semantic Versioning](https://semver.org/) scheme. The version number is stored in the `Versions` table. 22 | If a database has gone through one or more schema upgrades, then each previous version number will be stored in the `Versions` table, providing a kind of update audit trail. 23 | 24 | The actual version number stored in the `Versions` table is based on the nuget package version. However, new version rows are only added when new `schema-x.y.z.sql` scripts are added to the project. 25 | 26 | ## Schema Upgrade Process 27 | 28 | The Durable Task extension uses the following process to upgrade the database schema: 29 | 30 | 1. If the `Versions` table does not exist, then the `schema-1.0.0.sql` script is executed to create the schema. 31 | 1. If the `Versions` table exists, then the latest version number is read from the table. 32 | 1. If there are any schema scripts with a version number greater than the latest version number, then the scripts are executed in order to upgrade the schema. 33 | 1. After all the schema scripts have been executed, the `logic.sql` script is executed to create or update the stored procedures. 34 | 1. Finally, the `permissions.sql` script is executed to grant permissions to the Durable Task extension. 35 | 36 | In Azure Durable Functions, this process happens each time the application starts running, so the database schema will always be up-to-date with the latest version of the Durable Task extension. 37 | When using the Durable Task Framework directly, this process happens when `CreateAsync` or `CreateIfNotExistsAsync` methods of `SqlOrchestrationService` are called. 38 | 39 | ## Changing Database Schema 40 | 41 | The existing `schema-x.y.z.sql` files generally should NOT be modified after they are published. If you need to make changes to the database schema, follow these steps: 42 | 43 | 1. Create a new `schema-x.y.z.sql` script file with the new schema. The `x.y.z` numbers should match the new nuget package version that will be shipped with this new script. 44 | 1. Copy/paste the generic warning comments from any existing `schema-x.y.z.sql` files into the new script file as appropriate. 45 | 1. For adding new columns or indexes, use the appropriate `ALTER TABLE` statements as well as the `IF NOT EXISTS` syntax to avoid errors if the column or index already exists. These scripts must be safe to run multiple times. 46 | 1. For making changes to custom types, add `DROP TYPE` statements in the `schema-x.y.z.sql` file and then update the existing `CREATE TYPE` statements in the `logic.sql` file to ensure that those types get recreated with the newest schema. 47 | 48 | ## Changing Stored Procedures or Permissions 49 | 50 | The `logic.sql` and `permissions.sql` files can be modified as needed. These files are not versioned and are executed every time the application starts (Azure Durable Functions) or every time `CreateAsync` or `CreateIfNotExistsAsync` is called (Durable Task Framework). 51 | 52 | ## Testing Schema Changes 53 | 54 | There are several tests which validate the database schema included in this project. Most of these tests are in the [`DatabaseManagement.cs`](../../../test/DurableTask.SqlServer.Tests/Integration/DatabaseManagement.cs) file. 55 | When making schema changes, some of those tests will fail and will need to be updated. Some updates include: 56 | 57 | * Multiple test methods (`CanCreateAndDropSchema`, `CanCreateAndDropMultipleSchemas`, `CanCreateIfNotExists`, etc.) will need to be updated to list the new `schema-x.y.z.sql` script file name. 58 | * The `ValidateDatabaseSchemaAsync` method will need to be updated to check for the newest schema version number. 59 | 60 | ## Testing Database Upgrades 61 | 62 | The [UpgradeTests.cs](../../../test/DurableTask.SqlServer.Tests/Integration/UpgradeTests.cs) file contains tests which validate that the database schema upgrade process works correctly. 63 | It works by: 64 | 65 | * Restoring a backed-up database based on the `1.0.0` schema to a local SQL Server instance. 66 | * Starting an app that requires a newer schema version to trigger an automatic schema upgrade. 67 | * Runs a mix of new and old orchestrations to ensure that the schema upgrade was successful and that no data was lost. 68 | 69 | This test is critical to ensure that end-users won't be negatively impacted by schema changes. 70 | Unfortunately, it is not possible to run this test in CI yet until additional changes are made to support restoring database backups using Docker containers. 71 | However, it can be run locally on a Windows OS by following these steps: 72 | 73 | 1. Install [SQL Server Express or Developer](https://www.microsoft.com/sql-server/sql-server-downloads) on your local Windows machine, if it's not already installed. 74 | 1. Open the `UpgradeTests.cs` file and delete the `Skip` property in the `[Theory]` attribute on the `ValidateUpgradedOrchestrations` test method. 75 | 1. Run the `ValidateUpgradedOrchestrations` test manually in Visual Studio or using `dotnet test`. The test should pass. 76 | 77 | In a future update, we will add support for running this test in CI using Docker containers. 78 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/Scripts/drop-schema.sql: -------------------------------------------------------------------------------- 1 | -- Copyright (c) Microsoft Corporation. 2 | -- Licensed under the MIT License. 3 | 4 | -- Functions 5 | DROP FUNCTION IF EXISTS __SchemaNamePlaceholder__.CurrentTaskHub 6 | DROP FUNCTION IF EXISTS __SchemaNamePlaceholder__.GetScaleMetric 7 | DROP FUNCTION IF EXISTS __SchemaNamePlaceholder__.GetScaleRecommendation 8 | 9 | -- Views 10 | DROP VIEW IF EXISTS __SchemaNamePlaceholder__.vHistory 11 | DROP VIEW IF EXISTS __SchemaNamePlaceholder__.vInstances 12 | 13 | -- Public Sprocs 14 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__.CreateInstance 15 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__.GetInstanceHistory 16 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__.QuerySingleOrchestration 17 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__.RaiseEvent 18 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__.SetGlobalSetting 19 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__.TerminateInstance 20 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__.PurgeInstanceStateByID 21 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__.PurgeInstanceStateByTime 22 | 23 | -- Private sprocs 24 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._AddOrchestrationEvents 25 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._CheckpointOrchestration 26 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._CompleteTasks 27 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._DiscardEventsAndUnlockInstance 28 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._GetVersions 29 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._LockNextOrchestration 30 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._LockNextTask 31 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._QueryManyOrchestrations 32 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._RenewOrchestrationLocks 33 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._RenewTaskLocks 34 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._UpdateVersion 35 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._RewindInstance 36 | DROP PROCEDURE IF EXISTS __SchemaNamePlaceholder__._RewindInstanceRecursive 37 | 38 | -- Tables 39 | DROP TABLE IF EXISTS __SchemaNamePlaceholder__.Versions 40 | DROP TABLE IF EXISTS __SchemaNamePlaceholder__.NewTasks 41 | DROP TABLE IF EXISTS __SchemaNamePlaceholder__.NewEvents 42 | DROP TABLE IF EXISTS __SchemaNamePlaceholder__.History 43 | DROP TABLE IF EXISTS __SchemaNamePlaceholder__.Instances 44 | DROP TABLE IF EXISTS __SchemaNamePlaceholder__.Payloads 45 | DROP TABLE IF EXISTS __SchemaNamePlaceholder__.GlobalSettings 46 | 47 | -- Custom types 48 | DROP TYPE IF EXISTS __SchemaNamePlaceholder__.HistoryEvents 49 | DROP TYPE IF EXISTS __SchemaNamePlaceholder__.InstanceIDs 50 | DROP TYPE IF EXISTS __SchemaNamePlaceholder__.MessageIDs 51 | DROP TYPE IF EXISTS __SchemaNamePlaceholder__.OrchestrationEvents 52 | DROP TYPE IF EXISTS __SchemaNamePlaceholder__.TaskEvents 53 | 54 | -- This must be the last DROP statement related to schema 55 | DROP SCHEMA IF EXISTS __SchemaNamePlaceholder__ 56 | 57 | -- Roles: all members have to be dropped before the role can be dropped 58 | DECLARE @rolename sysname = '__SchemaNamePlaceholder___runtime'; 59 | DECLARE @cmd AS nvarchar(MAX) = N''; 60 | SELECT @cmd = @cmd + ' 61 | ALTER ROLE ' + QUOTENAME(@rolename) + ' DROP MEMBER ' + QUOTENAME(members.[name]) + ';' 62 | FROM sys.database_role_members AS rolemembers 63 | JOIN sys.database_principals AS roles 64 | ON roles.[principal_id] = rolemembers.[role_principal_id] 65 | JOIN sys.database_principals AS members 66 | ON members.[principal_id] = rolemembers.[member_principal_id] 67 | WHERE roles.[name] = @rolename 68 | EXEC(@cmd); 69 | 70 | -- Using EXEC 71 | DROP ROLE IF EXISTS __SchemaNamePlaceholder___runtime 72 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/Scripts/permissions.sql: -------------------------------------------------------------------------------- 1 | -- Copyright (c) Microsoft Corporation. 2 | -- Licensed under the MIT License. 3 | 4 | -- Security 5 | IF DATABASE_PRINCIPAL_ID('__SchemaNamePlaceholder___runtime') IS NULL 6 | BEGIN 7 | -- This is the role to which all low-privilege user accounts should be associated using 8 | -- the 'ALTER ROLE dt_runtime ADD MEMBER []' statement. 9 | CREATE ROLE __SchemaNamePlaceholder___runtime 10 | END 11 | 12 | -- Each stored procedure that is granted to dt_runtime must limits access to data based 13 | -- on the task hub since that is. that no 14 | -- database user can access data created by another database user. 15 | 16 | -- Functions 17 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__.GetScaleMetric TO __SchemaNamePlaceholder___runtime 18 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__.GetScaleRecommendation TO __SchemaNamePlaceholder___runtime 19 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__.CurrentTaskHub TO __SchemaNamePlaceholder___runtime 20 | 21 | -- Public sprocs 22 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__.CreateInstance TO __SchemaNamePlaceholder___runtime 23 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__.GetInstanceHistory TO __SchemaNamePlaceholder___runtime 24 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__.QuerySingleOrchestration TO __SchemaNamePlaceholder___runtime 25 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__.RaiseEvent TO __SchemaNamePlaceholder___runtime 26 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__.TerminateInstance TO __SchemaNamePlaceholder___runtime 27 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__.PurgeInstanceStateByID TO __SchemaNamePlaceholder___runtime 28 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__.PurgeInstanceStateByTime TO __SchemaNamePlaceholder___runtime 29 | 30 | -- Internal sprocs 31 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._AddOrchestrationEvents TO __SchemaNamePlaceholder___runtime 32 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._CheckpointOrchestration TO __SchemaNamePlaceholder___runtime 33 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._CompleteTasks TO __SchemaNamePlaceholder___runtime 34 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._DiscardEventsAndUnlockInstance TO __SchemaNamePlaceholder___runtime 35 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._GetVersions TO __SchemaNamePlaceholder___runtime 36 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._LockNextOrchestration TO __SchemaNamePlaceholder___runtime 37 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._LockNextTask TO __SchemaNamePlaceholder___runtime 38 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._QueryManyOrchestrations TO __SchemaNamePlaceholder___runtime 39 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._RenewOrchestrationLocks TO __SchemaNamePlaceholder___runtime 40 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._RenewTaskLocks TO __SchemaNamePlaceholder___runtime 41 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._UpdateVersion TO __SchemaNamePlaceholder___runtime 42 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._RewindInstance TO __SchemaNamePlaceholder___runtime 43 | GRANT EXECUTE ON OBJECT::__SchemaNamePlaceholder__._RewindInstanceRecursive TO __SchemaNamePlaceholder___runtime 44 | 45 | -- Types 46 | GRANT EXECUTE ON TYPE::__SchemaNamePlaceholder__.HistoryEvents TO __SchemaNamePlaceholder___runtime 47 | GRANT EXECUTE ON TYPE::__SchemaNamePlaceholder__.MessageIDs TO __SchemaNamePlaceholder___runtime 48 | GRANT EXECUTE ON TYPE::__SchemaNamePlaceholder__.InstanceIDs TO __SchemaNamePlaceholder___runtime 49 | GRANT EXECUTE ON TYPE::__SchemaNamePlaceholder__.OrchestrationEvents TO __SchemaNamePlaceholder___runtime 50 | GRANT EXECUTE ON TYPE::__SchemaNamePlaceholder__.TaskEvents TO __SchemaNamePlaceholder___runtime 51 | 52 | GO -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/Scripts/schema-1.2.0.sql: -------------------------------------------------------------------------------- 1 | -- Copyright (c) Microsoft Corporation. 2 | -- Licensed under the MIT License. 3 | 4 | -- PERSISTENT SCHEMA OBJECTS (tables, indexes, types, etc.) 5 | -- 6 | -- The contents of this file must never be changed after 7 | -- being published. Any schema changes must be done in 8 | -- new schema-{major}.{minor}.{patch}.sql scripts. 9 | 10 | -- Add a new TraceContext column to the Instances table 11 | IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('__SchemaNamePlaceholder__.Instances') AND name = 'TraceContext') 12 | ALTER TABLE __SchemaNamePlaceholder__.Instances ADD TraceContext varchar(800) NULL 13 | 14 | -- Add a new TraceContext column to the History table 15 | IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('__SchemaNamePlaceholder__.History') AND name = 'TraceContext') 16 | ALTER TABLE __SchemaNamePlaceholder__.History ADD TraceContext varchar(800) NULL 17 | 18 | -- Add a new TraceContext column to the NewEvents table 19 | IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('__SchemaNamePlaceholder__.NewEvents') AND name = 'TraceContext') 20 | ALTER TABLE __SchemaNamePlaceholder__.NewEvents ADD TraceContext varchar(800) NULL 21 | 22 | -- Add a new TraceContext column to the NewTasks table 23 | IF NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID('__SchemaNamePlaceholder__.NewTasks') AND name = 'TraceContext') 24 | ALTER TABLE __SchemaNamePlaceholder__.NewTasks ADD TraceContext varchar(800) NULL 25 | 26 | -- Drop custom types that have schema changes. They will be recreated in logic.sql, which executes last. 27 | -- In this release, we have changes to HistoryEvents, OrchestrationEvents, and TaskEvents to add TraceContext fields. 28 | -- In order to drop the types, we must first drop all stored procedures that depend on them, and then drop the types. 29 | -- One way to discover all the stored procs that depend on the types is to query sys.sql_expression_dependencies 30 | -- (credit to https://www.mssqltips.com/sqlservertip/6114/how-to-alter-user-defined-table-type-in-sql-server/): 31 | 32 | /* 33 | SELECT DISTINCT [types].name FROM ( 34 | SELECT s.name as [schema], o.name, def = OBJECT_DEFINITION(d.referencing_id), d.referenced_entity_name 35 | FROM sys.sql_expression_dependencies AS d 36 | INNER JOIN sys.objects AS o 37 | ON d.referencing_id = o.[object_id] 38 | INNER JOIN sys.schemas AS s 39 | ON o.[schema_id] = s.[schema_id] 40 | WHERE d.referenced_database_name IS NULL 41 | AND d.referenced_class_desc = 'TYPE' 42 | AND d.referenced_entity_name IN ('HistoryEvents', 'OrchestrationEvents', 'TaskEvents') 43 | ) [types] 44 | */ 45 | 46 | -- First, drop the referencing stored procedures 47 | IF OBJECT_ID('__SchemaNamePlaceholder__._AddOrchestrationEvents') IS NOT NULL 48 | DROP PROCEDURE __SchemaNamePlaceholder__._AddOrchestrationEvents 49 | IF OBJECT_ID('__SchemaNamePlaceholder__._CheckpointOrchestration') IS NOT NULL 50 | DROP PROCEDURE __SchemaNamePlaceholder__._CheckpointOrchestration 51 | IF OBJECT_ID('__SchemaNamePlaceholder__._CompleteTasks') IS NOT NULL 52 | DROP PROCEDURE __SchemaNamePlaceholder__._CompleteTasks 53 | 54 | -- Next, drop the types that we are changing 55 | IF TYPE_ID('__SchemaNamePlaceholder__.HistoryEvents') IS NOT NULL 56 | DROP TYPE __SchemaNamePlaceholder__.HistoryEvents 57 | IF TYPE_ID('__SchemaNamePlaceholder__.OrchestrationEvents') IS NOT NULL 58 | DROP TYPE __SchemaNamePlaceholder__.OrchestrationEvents 59 | IF TYPE_ID('__SchemaNamePlaceholder__.TaskEvents') IS NOT NULL 60 | DROP TYPE __SchemaNamePlaceholder__.TaskEvents 61 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/SqlIdentifier.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer 5 | { 6 | using System; 7 | using System.Text; 8 | 9 | static class SqlIdentifier 10 | { 11 | public static string Escape(string value) 12 | { 13 | if (value == null) 14 | { 15 | throw new ArgumentNullException(nameof(value)); 16 | } 17 | 18 | if (value == "") 19 | { 20 | throw new ArgumentException("Value cannot be empty.", nameof(value)); 21 | } 22 | 23 | StringBuilder builder = new StringBuilder(); 24 | 25 | builder.Append('['); 26 | foreach (char c in value) 27 | { 28 | if (c == ']') 29 | { 30 | builder.Append(']'); 31 | } 32 | 33 | builder.Append(c); 34 | } 35 | builder.Append(']'); 36 | 37 | return builder.ToString(); 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/SqlOrchestrationQuery.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using DurableTask.Core; 9 | 10 | /// 11 | /// Represents database orchestration query parameters. 12 | /// 13 | public class SqlOrchestrationQuery 14 | { 15 | /// 16 | /// The maximum number of records to return. 17 | /// 18 | public int PageSize { get; set; } = 100; 19 | 20 | /// 21 | /// Gets or sets the zero-indexed page number for paginated queries. 22 | /// 23 | public int PageNumber { get; set; } 24 | 25 | /// 26 | /// Gets or sets a value indicating whether to fetch orchestration inputs. 27 | /// 28 | public bool FetchInput { get; set; } = true; 29 | 30 | /// 31 | /// Gets or sets a value indicating whether to fetch orchestration outputs. 32 | /// 33 | public bool FetchOutput { get; set; } = true; 34 | 35 | /// 36 | /// Gets or sets a minimum creation time filter. Only orchestrations created 37 | /// after this date are selected. 38 | /// 39 | public DateTime CreatedTimeFrom { get; set; } 40 | 41 | /// 42 | /// Gets or sets a maximum creation time filter. Only orchestrations created 43 | /// before this date are selected. 44 | /// 45 | public DateTime CreatedTimeTo { get; set; } 46 | 47 | /// 48 | /// Gets or sets a set of orchestration status values to filter orchestrations by. 49 | /// 50 | public ISet? StatusFilter { get; set; } 51 | 52 | /// 53 | /// Gets or sets an instance ID prefix to use for filtering orchestration instances. 54 | /// 55 | public string? InstanceIdPrefix { get; set; } 56 | 57 | /// 58 | /// Determines whether the query will retrieve only parent instances. 59 | /// 60 | public bool ExcludeSubOrchestrations { get; set; } = false; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/SqlTypes/HistoryEventSqlType.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.SqlTypes 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Data; 9 | using System.Data.SqlTypes; 10 | using DurableTask.Core; 11 | using DurableTask.Core.History; 12 | using Microsoft.Data.SqlClient; 13 | using Microsoft.Data.SqlClient.Server; 14 | 15 | static class HistoryEventSqlType 16 | { 17 | static readonly SqlMetaData[] HistoryEventSchema = new SqlMetaData[] 18 | { 19 | // IMPORTANT: The order and schema of these items must always match the order of the SQL type in logic.sql 20 | new SqlMetaData("InstanceID", SqlDbType.VarChar, 100), 21 | new SqlMetaData("ExecutionID", SqlDbType.VarChar, 50), 22 | new SqlMetaData("SequenceNumber", SqlDbType.BigInt), 23 | new SqlMetaData("EventType", SqlDbType.VarChar, 40), 24 | new SqlMetaData("Name", SqlDbType.VarChar, 300), 25 | new SqlMetaData("RuntimeStatus", SqlDbType.VarChar, 30), 26 | new SqlMetaData("TaskID", SqlDbType.Int), 27 | new SqlMetaData("Timestamp", SqlDbType.DateTime2), 28 | new SqlMetaData("IsPlayed", SqlDbType.Bit), 29 | new SqlMetaData("VisibleTime", SqlDbType.DateTime2), 30 | new SqlMetaData("Reason", SqlDbType.VarChar, -1 /* max */), 31 | new SqlMetaData("PayloadText", SqlDbType.VarChar, -1 /* max */), 32 | new SqlMetaData("PayloadID", SqlDbType.UniqueIdentifier), 33 | new SqlMetaData("ParentInstanceID", SqlDbType.VarChar, 100), 34 | new SqlMetaData("Version", SqlDbType.VarChar, 100), 35 | new SqlMetaData("TraceContext", SqlDbType.VarChar, 800), 36 | }; 37 | 38 | static class ColumnOrdinals 39 | { 40 | // IMPORTANT: Must be kept in sync with the schema definition above 41 | public const int InstanceID = 0; 42 | public const int ExecutionID = 1; 43 | public const int SequenceNumber = 2; 44 | public const int EventType = 3; 45 | public const int Name = 4; 46 | public const int RuntimeStatus = 5; 47 | public const int TaskID = 6; 48 | public const int Timestamp = 7; 49 | public const int IsPlayed = 8; 50 | public const int VisibleTime = 9; 51 | public const int Reason = 10; 52 | public const int PayloadText = 11; 53 | public const int PayloadID = 12; 54 | public const int ParentInstanceID = 13; 55 | public const int Version = 14; 56 | public const int TraceContext = 15; 57 | }; 58 | 59 | public static SqlParameter AddHistoryEventsParameter( 60 | this SqlParameterCollection commandParameters, 61 | string paramName, 62 | IEnumerable newEventCollection, 63 | OrchestrationInstance instance, 64 | int nextSequenceNumber, 65 | EventPayloadMap eventPayloadMap, 66 | string schemaName) 67 | { 68 | SqlParameter param = commandParameters.Add(paramName, SqlDbType.Structured); 69 | param.TypeName = $"{schemaName}.HistoryEvents"; 70 | param.Value = ToHistoryEventsParameter(newEventCollection, instance, nextSequenceNumber, eventPayloadMap); 71 | return param; 72 | } 73 | 74 | static IEnumerable ToHistoryEventsParameter( 75 | IEnumerable historyEvents, 76 | OrchestrationInstance instance, 77 | int nextSequenceNumber, 78 | EventPayloadMap eventPayloadMap) 79 | { 80 | var record = new SqlDataRecord(HistoryEventSchema); 81 | foreach (HistoryEvent e in historyEvents) 82 | { 83 | record.SetSqlInt64(ColumnOrdinals.SequenceNumber, nextSequenceNumber++); 84 | record.SetSqlString(ColumnOrdinals.InstanceID, instance.InstanceId); 85 | record.SetSqlString(ColumnOrdinals.ExecutionID, instance.ExecutionId); 86 | record.SetSqlString(ColumnOrdinals.EventType, e.EventType.ToString()); 87 | record.SetSqlString(ColumnOrdinals.Name, SqlUtils.GetName(e)); 88 | record.SetSqlString(ColumnOrdinals.RuntimeStatus, SqlUtils.GetRuntimeStatus(e)); 89 | record.SetSqlInt32(ColumnOrdinals.TaskID, SqlUtils.GetTaskId(e)); 90 | record.SetDateTime(ColumnOrdinals.Timestamp, e.Timestamp); 91 | record.SetBoolean(ColumnOrdinals.IsPlayed, e.IsPlayed); 92 | record.SetDateTime(ColumnOrdinals.VisibleTime, SqlUtils.GetVisibleTime(e)); 93 | record.SetSqlString(ColumnOrdinals.TraceContext, SqlUtils.GetTraceContext(e)); 94 | 95 | if (eventPayloadMap.TryGetPayloadId(e, out Guid existingPayloadId)) 96 | { 97 | // We already have a payload saved in the DB for this event. Send only the payload ID. 98 | record.SetSqlString(ColumnOrdinals.Reason, SqlString.Null); 99 | record.SetSqlString(ColumnOrdinals.PayloadText, SqlString.Null); 100 | record.SetSqlGuid(ColumnOrdinals.PayloadID, existingPayloadId); 101 | } 102 | else 103 | { 104 | // This path is expected for ExecutionCompleted, possibly others? 105 | SqlString reason = SqlUtils.GetReason(e); 106 | record.SetSqlString(ColumnOrdinals.Reason, reason); 107 | SqlString payload = SqlUtils.GetPayloadText(e); 108 | record.SetSqlString(ColumnOrdinals.PayloadText, payload); 109 | SqlGuid newPayloadId = reason.IsNull && payload.IsNull ? SqlGuid.Null : new SqlGuid(Guid.NewGuid()); 110 | record.SetSqlGuid(ColumnOrdinals.PayloadID, newPayloadId); 111 | } 112 | 113 | yield return record; 114 | } 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/SqlTypes/MessageIdSqlType.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.SqlTypes 5 | { 6 | using System.Collections.Generic; 7 | using System.Data; 8 | using DurableTask.Core; 9 | using Microsoft.Data.SqlClient; 10 | using Microsoft.Data.SqlClient.Server; 11 | 12 | static class MessageIdSqlType 13 | { 14 | static readonly SqlMetaData[] MessageIdSchema = new SqlMetaData[] 15 | { 16 | // IMPORTANT: The order and schema of these items must always match the order of the SQL type in logic.sql 17 | new SqlMetaData("InstanceID", SqlDbType.VarChar, 100), 18 | new SqlMetaData("SequenceNumber", SqlDbType.BigInt), 19 | }; 20 | 21 | public static SqlParameter AddMessageIdParameter( 22 | this SqlParameterCollection commandParameters, 23 | string paramName, 24 | IEnumerable messageCollection, 25 | string schemaName) 26 | { 27 | SqlParameter param = commandParameters.Add(paramName, SqlDbType.Structured); 28 | param.TypeName = $"{schemaName}.MessageIDs"; 29 | param.Value = ToMessageIDsParameter(messageCollection); 30 | return param; 31 | } 32 | 33 | public static SqlParameter AddMessageIdParameter( 34 | this SqlParameterCollection commandParameters, 35 | string paramName, 36 | TaskMessage message, 37 | string schemaName) 38 | { 39 | SqlParameter param = commandParameters.Add(paramName, SqlDbType.Structured); 40 | param.TypeName = $"{schemaName}.MessageIDs"; 41 | param.Value = ToMessageIDsParameter(message); 42 | return param; 43 | } 44 | 45 | static IEnumerable ToMessageIDsParameter(IEnumerable messages) 46 | { 47 | var record = new SqlDataRecord(MessageIdSchema); 48 | foreach (TaskMessage message in messages) 49 | { 50 | yield return PopulateMessageIdRecord(message, record); 51 | } 52 | } 53 | 54 | static IEnumerable ToMessageIDsParameter(TaskMessage message) 55 | { 56 | var record = new SqlDataRecord(MessageIdSchema); 57 | yield return PopulateMessageIdRecord(message, record); 58 | } 59 | 60 | static SqlDataRecord PopulateMessageIdRecord(TaskMessage message, SqlDataRecord record) 61 | { 62 | // IMPORTANT: The order of these columns must always match the order of the SQL type 63 | record.SetString(0, message.OrchestrationInstance.InstanceId); 64 | record.SetInt64(1, message.SequenceNumber); 65 | return record; 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/SqlTypes/TaskEventSqlType.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.SqlTypes 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Data; 9 | using System.Data.SqlTypes; 10 | using System.Linq; 11 | using DurableTask.Core; 12 | using Microsoft.Data.SqlClient; 13 | using Microsoft.Data.SqlClient.Server; 14 | 15 | static class TaskEventSqlType 16 | { 17 | static readonly SqlMetaData[] TaskEventSchema = new SqlMetaData[] 18 | { 19 | // IMPORTANT: Must be kept in sync with the database schema 20 | new SqlMetaData("InstanceID", SqlDbType.VarChar, 100), 21 | new SqlMetaData("ExecutionID", SqlDbType.VarChar, 50), 22 | new SqlMetaData("Name", SqlDbType.VarChar, 300), 23 | new SqlMetaData("EventType", SqlDbType.VarChar, 40), 24 | new SqlMetaData("TaskID", SqlDbType.Int), 25 | new SqlMetaData("VisibleTime", SqlDbType.DateTime2), 26 | new SqlMetaData("LockedBy", SqlDbType.VarChar, 100), 27 | new SqlMetaData("LockExpiration", SqlDbType.DateTime2), 28 | new SqlMetaData("Reason", SqlDbType.VarChar, -1 /* max */), 29 | new SqlMetaData("PayloadText", SqlDbType.VarChar, -1 /* max */), 30 | new SqlMetaData("PayloadID", SqlDbType.UniqueIdentifier), 31 | new SqlMetaData("Version", SqlDbType.VarChar, 100), 32 | new SqlMetaData("TraceContext", SqlDbType.VarChar, 800), 33 | }; 34 | 35 | static class ColumnOrdinals 36 | { 37 | // IMPORTANT: Must be kept in sync with the database schema 38 | public const int InstanceID = 0; 39 | public const int ExecutionID = 1; 40 | public const int Name = 2; 41 | public const int EventType = 3; 42 | public const int TaskID = 4; 43 | public const int VisibleTime = 5; 44 | public const int LockedBy = 6; 45 | public const int LockExpiration = 7; 46 | public const int Reason = 8; 47 | public const int PayloadText = 9; 48 | public const int PayloadId = 10; 49 | public const int Version = 11; 50 | public const int TraceContext = 12; 51 | } 52 | 53 | public static SqlParameter AddTaskEventsParameter( 54 | this SqlParameterCollection commandParameters, 55 | string paramName, 56 | IList outboundMessages, 57 | EventPayloadMap eventPayloadMap, 58 | string schemaName) 59 | { 60 | SqlParameter param = commandParameters.Add(paramName, SqlDbType.Structured); 61 | param.TypeName = $"{schemaName}.TaskEvents"; 62 | param.Value = ToTaskMessagesParameter(outboundMessages, eventPayloadMap); 63 | return param; 64 | } 65 | 66 | public static SqlParameter AddTaskEventsParameter( 67 | this SqlParameterCollection commandParameters, 68 | string paramName, 69 | TaskMessage message, 70 | string schemaName) 71 | { 72 | SqlParameter param = commandParameters.Add(paramName, SqlDbType.Structured); 73 | param.TypeName = $"{schemaName}.TaskEvents"; 74 | param.Value = ToTaskMessageParameter(message); 75 | return param; 76 | } 77 | 78 | static IEnumerable? ToTaskMessagesParameter( 79 | this IEnumerable messages, 80 | EventPayloadMap? eventPayloadMap) 81 | { 82 | if (!messages.Any()) 83 | { 84 | return null; 85 | } 86 | 87 | return GetTaskEventRecords(); 88 | 89 | // Using a local function to support using null and yield syntax in the same method 90 | IEnumerable GetTaskEventRecords() 91 | { 92 | var record = new SqlDataRecord(TaskEventSchema); 93 | foreach (TaskMessage msg in messages) 94 | { 95 | yield return PopulateTaskMessageRecord(msg, record, eventPayloadMap); 96 | } 97 | } 98 | } 99 | 100 | static IEnumerable ToTaskMessageParameter(TaskMessage msg) 101 | { 102 | var record = new SqlDataRecord(TaskEventSchema); 103 | yield return PopulateTaskMessageRecord(msg, record, eventPayloadMap: null); 104 | } 105 | 106 | static SqlDataRecord PopulateTaskMessageRecord( 107 | TaskMessage msg, 108 | SqlDataRecord record, 109 | EventPayloadMap? eventPayloadMap) 110 | { 111 | record.SetSqlString(ColumnOrdinals.InstanceID, msg.OrchestrationInstance.InstanceId); 112 | record.SetSqlString(ColumnOrdinals.ExecutionID, msg.OrchestrationInstance.ExecutionId); 113 | record.SetSqlString(ColumnOrdinals.Name, SqlUtils.GetName(msg.Event)); 114 | record.SetSqlString(ColumnOrdinals.EventType, msg.Event.EventType.ToString()); 115 | record.SetSqlInt32(ColumnOrdinals.TaskID, SqlUtils.GetTaskId(msg.Event)); 116 | record.SetDateTime(ColumnOrdinals.VisibleTime, SqlUtils.GetVisibleTime(msg.Event)); 117 | 118 | SqlString reasonText = SqlUtils.GetReason(msg.Event); 119 | record.SetSqlString(ColumnOrdinals.Reason, reasonText); 120 | SqlString payloadText = SqlUtils.GetPayloadText(msg.Event); 121 | record.SetSqlString(ColumnOrdinals.PayloadText, payloadText); 122 | 123 | SqlGuid sqlPayloadId = SqlGuid.Null; 124 | if (eventPayloadMap != null && eventPayloadMap.TryGetPayloadId(msg.Event, out Guid payloadId)) 125 | { 126 | // There is already a payload ID associated with this event 127 | sqlPayloadId = payloadId; 128 | } 129 | else if (!payloadText.IsNull || !reasonText.IsNull) 130 | { 131 | // This is a new event and needs a new payload ID 132 | // CONSIDER: Make this GUID a semi-human-readable deterministic value 133 | sqlPayloadId = Guid.NewGuid(); 134 | } 135 | 136 | record.SetSqlGuid(ColumnOrdinals.PayloadId, sqlPayloadId); 137 | 138 | // Optionally, the LockedBy and LockExpiration fields can be specified 139 | // to pre-lock task work items for this particular node. 140 | 141 | record.SetSqlString(ColumnOrdinals.Version, SqlUtils.GetVersion(msg.Event)); 142 | record.SetSqlString(ColumnOrdinals.TraceContext, SqlUtils.GetTraceContext(msg.Event)); 143 | return record; 144 | } 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/Utils/AsyncAutoResetEvent.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | 11 | // Inspired by https://blogs.msdn.microsoft.com/pfxteam/2012/02/11/building-async-coordination-primitives-part-2-asyncautoresetevent/ 12 | class AsyncAutoResetEvent 13 | { 14 | readonly LinkedList> waiters = 15 | new LinkedList>(); 16 | 17 | bool isSignaled; 18 | 19 | public AsyncAutoResetEvent(bool signaled) 20 | { 21 | this.isSignaled = signaled; 22 | } 23 | 24 | public Task WaitAsync(TimeSpan timeout) 25 | { 26 | return this.WaitAsync(timeout, CancellationToken.None); 27 | } 28 | 29 | public async Task WaitAsync(TimeSpan timeout, CancellationToken cancellationToken) 30 | { 31 | TaskCompletionSource tcs; 32 | 33 | lock (this.waiters) 34 | { 35 | if (this.isSignaled) 36 | { 37 | this.isSignaled = false; 38 | return true; 39 | } 40 | else if (timeout == TimeSpan.Zero) 41 | { 42 | return this.isSignaled; 43 | } 44 | else 45 | { 46 | // If we ever upgrade to .NET 4.6, we should use TaskCreationOptions.RunContinuationsAsynchronously 47 | tcs = new TaskCompletionSource(); 48 | this.waiters.AddLast(tcs); 49 | } 50 | } 51 | 52 | Task winner = await Task.WhenAny(tcs.Task, Task.Delay(timeout, cancellationToken)); 53 | if (winner == tcs.Task) 54 | { 55 | // The task was signaled. 56 | return true; 57 | } 58 | else 59 | { 60 | // We timed-out; remove our reference to the task. 61 | lock (this.waiters) 62 | { 63 | if (tcs.Task.IsCompleted) 64 | { 65 | // We were signaled and timed out at about the same time. 66 | return true; 67 | } 68 | 69 | // This is an O(n) operation since waiters is a LinkedList. 70 | this.waiters.Remove(tcs); 71 | return false; 72 | } 73 | } 74 | } 75 | 76 | public void Set() 77 | { 78 | lock (this.waiters) 79 | { 80 | if (this.waiters.Count > 0) 81 | { 82 | // Signal the first task in the waiters list. This must be done on a new 83 | // thread to avoid stack-dives and situations where we try to complete the 84 | // same result multiple times. 85 | TaskCompletionSource tcs = this.waiters.First.Value; 86 | Task.Run(() => tcs.SetResult(true)); 87 | this.waiters.RemoveFirst(); 88 | } 89 | else if (!this.isSignaled) 90 | { 91 | // No tasks are pending 92 | this.isSignaled = true; 93 | } 94 | } 95 | } 96 | 97 | public override string ToString() 98 | { 99 | return $"Signaled: {this.isSignaled.ToString()}, Waiters: {this.waiters.Count.ToString()}"; 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /src/DurableTask.SqlServer/Utils/NetFxCompat.cs: -------------------------------------------------------------------------------- 1 | #if NETSTANDARD2_0 // These methods are not available in .NET Standard 2.0 2 | namespace DurableTask.SqlServer 3 | { 4 | using System.Collections.Generic; 5 | using System.Data.Common; 6 | using System.Linq; 7 | using System.Threading.Tasks; 8 | 9 | public static class NetFxCompat 10 | { 11 | public static int GetInt32(this DbDataReader reader, string columnName) 12 | { 13 | int ordinal = reader.GetOrdinal(columnName); 14 | return reader.GetInt32(ordinal); 15 | } 16 | 17 | public static string GetString(this DbDataReader reader, string columnName) 18 | { 19 | int ordinal = reader.GetOrdinal(columnName); 20 | return reader.GetString(ordinal); 21 | } 22 | 23 | public static Task CloseAsync(this DbConnection connection) 24 | { 25 | connection.Close(); 26 | return Task.CompletedTask; 27 | } 28 | 29 | public static Task BeginTransactionAsync(this DbConnection connection) 30 | { 31 | // BeginTransactionAsync is not available in the .NET Framework or .NET Standard 2.0 32 | return Task.FromResult(connection.BeginTransaction()); 33 | } 34 | 35 | public static Task CommitAsync(this DbTransaction transaction) 36 | { 37 | transaction.Commit(); 38 | return Task.CompletedTask; 39 | } 40 | 41 | public static Task RollbackAsync(this DbTransaction transaction) 42 | { 43 | transaction.Rollback(); 44 | return Task.CompletedTask; 45 | } 46 | 47 | public static IEnumerable Append(this IEnumerable source, T item) 48 | { 49 | if (source is ICollection collection) 50 | { 51 | collection.Add(item); 52 | return collection; 53 | } 54 | else 55 | { 56 | List list = source.ToList(); 57 | list.Add(item); 58 | return list; 59 | } 60 | } 61 | 62 | public static bool TryAdd(this IDictionary dict, TKey key, TValue value) 63 | { 64 | if (!dict.ContainsKey(key)) 65 | { 66 | dict.Add(key, value); 67 | return true; 68 | } 69 | 70 | return false; 71 | } 72 | 73 | // https://stackoverflow.com/questions/57047174/wheres-deconstruct-method-of-keyvaluepair-struct 74 | public static void Deconstruct(this KeyValuePair pair, out TKey key, out TValue value) 75 | { 76 | key = pair.Key; 77 | value = pair.Value; 78 | } 79 | } 80 | } 81 | #endif 82 | -------------------------------------------------------------------------------- /src/Functions.Worker.Extensions.DurableTask.SqlServer/Functions.Worker.Extensions.DurableTask.SqlServer.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | 7 | netstandard2.0 8 | Microsoft.Azure.Functions.Worker.Extensions.DurableTask.SqlServer 9 | 10 | 11 | 12 | 13 | $(AssemblyName) 14 | Azure Durable Functions SQL Provider 15 | Microsoft SQL provider for Azure Durable Functions. 16 | Microsoft;Azure;Functions;Durable;Task;Orchestration;Workflow;Activity;Reliable;SQL 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | <_Parameter1>Microsoft.DurableTask.SqlServer.AzureFunctions 27 | <_Parameter2>$(PackageVersion) 28 | <_Parameter3>true 29 | <_Parameter3_IsLiteral>true 30 | 31 | 32 | 33 | 34 | -------------------------------------------------------------------------------- /src/common.props: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 9.0 7 | enable 8 | embedded 9 | true 10 | true 11 | https://github.com/microsoft/durabletask-mssql 12 | true 13 | ../../sign.snk 14 | 15 | 16 | 17 | 18 | 1 19 | 5 20 | 1 21 | $(MajorVersion).$(MinorVersion).$(PatchVersion) 22 | 23 | $(MajorVersion).$(MinorVersion).0.0 24 | $(VersionPrefix).0 25 | 26 | $(VersionPrefix).$(FileVersionRevision) 27 | 28 | 29 | 30 | 31 | Microsoft 32 | © Microsoft Corporation. All rights reserved. 33 | logo.png 34 | MIT 35 | true 36 | $(RepositoryUrl) 37 | $(RepositoryUrl)/releases/ 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | true 52 | content/SBOM 53 | 54 | 55 | 56 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.AzureFunctions.Tests/DurableTask.SqlServer.AzureFunctions.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | false 6 | true 7 | 9.0 8 | enable 9 | ..\..\sign.snk 10 | 11 | 12 | DF0108;DF0110;DF0305 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.AzureFunctions.Tests/TargetBasedScalingTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.AzureFunctions.Tests 5 | { 6 | using System.Threading.Tasks; 7 | using DurableTask.Core; 8 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 9 | using Microsoft.Azure.WebJobs.Host.Scale; 10 | using Moq; 11 | using Xunit; 12 | 13 | public class TargetBasedScalingTests 14 | { 15 | readonly Mock metricsProviderMock; 16 | readonly Mock orchestrationServiceMock; 17 | 18 | public TargetBasedScalingTests() 19 | { 20 | this.orchestrationServiceMock = new Mock(MockBehavior.Strict); 21 | 22 | SqlOrchestrationService? nullServiceArg = null; // not needed for this test 23 | this.metricsProviderMock = new Mock( 24 | behavior: MockBehavior.Strict, 25 | nullServiceArg!); 26 | } 27 | 28 | [Theory] 29 | [InlineData(0)] 30 | [InlineData(10)] 31 | [InlineData(20)] 32 | public async Task TargetBasedScalingTest(int expectedTargetWorkerCount) 33 | { 34 | var durabilityProviderMock = new Mock( 35 | MockBehavior.Strict, 36 | "storageProviderName", 37 | this.orchestrationServiceMock.Object, 38 | new Mock().Object, 39 | "connectionName"); 40 | 41 | var sqlScaleMetric = new SqlScaleMetric() 42 | { 43 | RecommendedReplicaCount = expectedTargetWorkerCount, 44 | }; 45 | 46 | this.metricsProviderMock.Setup(m => m.GetMetricsAsync(null)).ReturnsAsync(sqlScaleMetric); 47 | 48 | var targetScaler = new SqlTargetScaler( 49 | "functionId", 50 | this.metricsProviderMock.Object); 51 | 52 | TargetScalerResult result = await targetScaler.GetScaleResultAsync(new TargetScalerContext()); 53 | 54 | Assert.Equal(expectedTargetWorkerCount, result.TargetWorkerCount); 55 | } 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.AzureFunctions.Tests/Utils.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.AzureFunctions.Tests 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Threading; 9 | using System.Threading.Tasks; 10 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 11 | 12 | static class Utils 13 | { 14 | // TODO: Make this a built-in API 15 | public static async Task WaitForCompletionAsync( 16 | this IDurableOrchestrationClient client, 17 | string instanceId, 18 | TimeSpan timeout) 19 | { 20 | using CancellationTokenSource cts = new CancellationTokenSource(timeout); 21 | return await client.WaitForCompletionAsync(instanceId, cts.Token); 22 | } 23 | 24 | // TODO: Make this a built-in API 25 | public static async Task WaitForCompletionAsync( 26 | this IDurableOrchestrationClient client, 27 | string instanceId, 28 | CancellationToken cancellationToken) 29 | { 30 | while (!cancellationToken.IsCancellationRequested) 31 | { 32 | DurableOrchestrationStatus status = await client.GetStatusAsync(instanceId); 33 | switch (status?.RuntimeStatus) 34 | { 35 | case OrchestrationRuntimeStatus.Canceled: 36 | case OrchestrationRuntimeStatus.Completed: 37 | case OrchestrationRuntimeStatus.Failed: 38 | case OrchestrationRuntimeStatus.Terminated: 39 | return status; 40 | } 41 | 42 | await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); 43 | } 44 | 45 | cancellationToken.ThrowIfCancellationRequested(); 46 | 47 | // Code should never reach here 48 | return null!; 49 | } 50 | 51 | // TODO: Make this a built-in API 52 | public static async Task WaitForStartAsync( 53 | this IDurableOrchestrationClient client, 54 | string instanceId, 55 | TimeSpan timeout) 56 | { 57 | using CancellationTokenSource cts = new CancellationTokenSource(timeout); 58 | return await client.WaitForStartAsync(instanceId, cts.Token); 59 | } 60 | 61 | // TODO: Make this a built-in API 62 | public static async Task WaitForStartAsync( 63 | this IDurableOrchestrationClient client, 64 | string instanceId, 65 | CancellationToken cancellationToken) 66 | { 67 | while (!cancellationToken.IsCancellationRequested) 68 | { 69 | DurableOrchestrationStatus status = await client.GetStatusAsync(instanceId); 70 | if (status != null && status.RuntimeStatus != OrchestrationRuntimeStatus.Pending) 71 | { 72 | return status; 73 | } 74 | 75 | await Task.Delay(TimeSpan.FromMilliseconds(500), cancellationToken); 76 | } 77 | 78 | cancellationToken.ThrowIfCancellationRequested(); 79 | 80 | // Code should never reach here 81 | return null!; 82 | } 83 | 84 | public static async Task ParallelForEachAsync(this IEnumerable items, int maxConcurrency, Func action) 85 | { 86 | List tasks; 87 | if (items is ICollection itemCollection) 88 | { 89 | tasks = new List(itemCollection.Count); 90 | } 91 | else 92 | { 93 | tasks = new List(); 94 | } 95 | 96 | using var semaphore = new SemaphoreSlim(maxConcurrency); 97 | foreach (T item in items) 98 | { 99 | tasks.Add(InvokeThrottledAction(item, action, semaphore)); 100 | } 101 | 102 | await Task.WhenAll(tasks); 103 | } 104 | 105 | static async Task InvokeThrottledAction(T item, Func action, SemaphoreSlim semaphore) 106 | { 107 | await semaphore.WaitAsync(); 108 | try 109 | { 110 | await action(item); 111 | } 112 | finally 113 | { 114 | semaphore.Release(); 115 | } 116 | } 117 | } 118 | } 119 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.AzureFunctions.Tests/WithoutMultiTenancyCoreScenarios.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.AzureFunctions.Tests 5 | { 6 | using System; 7 | using System.Threading.Tasks; 8 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 9 | using Xunit; 10 | using Xunit.Abstractions; 11 | 12 | [Collection("Integration")] 13 | public class WithoutMultiTenancyCoreScenarios : CoreScenarios 14 | { 15 | public WithoutMultiTenancyCoreScenarios(ITestOutputHelper output) 16 | : base(output, "TaskHubWithoutMultiTenancy", false) 17 | { 18 | } 19 | 20 | [Fact] 21 | public async Task When_Without_MultiTenancy_should_Return_Correct_Orchestration_By_TaskHub() 22 | { 23 | string otherTaskHubName = "SomeOtherTaskHub"; 24 | 25 | string currentTaskHubInstanceId = Guid.NewGuid().ToString(); 26 | await this.StartOrchestrationAsync(nameof(Functions.Sequence), instanceId: currentTaskHubInstanceId); 27 | 28 | string anotherTaskHubInstanceId = Guid.NewGuid().ToString(); 29 | await this.StartOrchestrationWithoutWaitingAsync(nameof(Functions.Sequence), instanceId: anotherTaskHubInstanceId, taskHub: otherTaskHubName); 30 | 31 | IDurableClient client = this.GetDurableClient(); 32 | var current = await client.GetStatusAsync(currentTaskHubInstanceId); 33 | Assert.NotNull(current); 34 | var otherInstance = await client.GetStatusAsync(anotherTaskHubInstanceId); 35 | Assert.Null(otherInstance); 36 | 37 | IDurableClient clientOtherTaskHub = this.GetDurableClient(otherTaskHubName); 38 | var currentFromOtherTaskHub = await clientOtherTaskHub.GetStatusAsync(currentTaskHubInstanceId); 39 | Assert.Null(currentFromOtherTaskHub); 40 | var otherInstanceFromOtherTaskHub = await clientOtherTaskHub.GetStatusAsync(anotherTaskHubInstanceId); 41 | Assert.NotNull(otherInstanceFromOtherTaskHub); 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.Tests/DatabaseBackups/DurableDB-v1.0.0.bak.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/microsoft/durabletask-mssql/7843abebe1d4c43dfe66253845ee815e57fa05c4/test/DurableTask.SqlServer.Tests/DatabaseBackups/DurableDB-v1.0.0.bak.zip -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.Tests/DurableTask.SqlServer.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net6.0 5 | false 6 | true 7 | 9.0 8 | ..\..\sign.snk 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | PreserveNewest 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.Tests/Integration/LegacyErrorPropagation.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.Tests.Integration 5 | { 6 | using System; 7 | using System.Threading.Tasks; 8 | using DurableTask.Core.Exceptions; 9 | using DurableTask.Core; 10 | using DurableTask.SqlServer.Tests.Utils; 11 | using Newtonsoft.Json; 12 | using Xunit; 13 | using Xunit.Abstractions; 14 | 15 | [Collection("Integration")] 16 | public class LegacyErrorPropagation : IAsyncLifetime 17 | { 18 | readonly TestService testService; 19 | 20 | public LegacyErrorPropagation(ITestOutputHelper output) 21 | { 22 | this.testService = new TestService(output); 23 | } 24 | 25 | Task IAsyncLifetime.InitializeAsync() => this.testService.InitializeAsync(legacyErrorPropagation: true); 26 | 27 | Task IAsyncLifetime.DisposeAsync() => this.testService.DisposeAsync(); 28 | 29 | [Fact] 30 | public async Task UncaughtOrchestrationException() 31 | { 32 | string errorMessage = "Kah-BOOOOOM!!!"; 33 | 34 | // The exception is expected to fail the orchestration execution 35 | TestInstance instance = await this.testService.RunOrchestration( 36 | null, 37 | orchestrationName: "OrchestrationWithException", 38 | implementation: (ctx, input) => throw new ApplicationException(errorMessage)); 39 | 40 | await instance.WaitForCompletion(expectedOutput: errorMessage, expectedStatus: OrchestrationStatus.Failed); 41 | } 42 | 43 | [Fact] 44 | public async Task UncaughtActivityException() 45 | { 46 | var exceptionToThrow = new ApplicationException("Kah-BOOOOOM!!!"); 47 | 48 | // Schedules a task that throws an uncaught exception 49 | TestInstance instance = await this.testService.RunOrchestration( 50 | null as string, 51 | orchestrationName: "OrchestrationWithActivityFailure", 52 | implementation: (ctx, input) => ctx.ScheduleTask("Throw", ""), 53 | activities: new[] { 54 | ("Throw", TestService.MakeActivity((ctx, input) => throw exceptionToThrow)), 55 | }); 56 | 57 | OrchestrationState state = await instance.WaitForCompletion(expectedStatus: OrchestrationStatus.Failed); 58 | Assert.Equal(exceptionToThrow.Message, state.Output); 59 | } 60 | 61 | [Fact] 62 | public async Task CatchActivityException() 63 | { 64 | var innerException = new InvalidOperationException("Oops"); 65 | var exceptionToThrow = new ApplicationException("Kah-BOOOOOM!!!", innerException); 66 | 67 | // Schedules a task that throws an exception, which is then caught by the orchestration 68 | TestInstance instance = await this.testService.RunOrchestration( 69 | null as string, 70 | orchestrationName: "OrchestrationWithActivityFailure", 71 | implementation: async (ctx, input) => 72 | { 73 | try 74 | { 75 | await ctx.ScheduleTask("Throw", ""); 76 | return null; // not expected 77 | } 78 | catch (TaskFailedException e) 79 | { 80 | return e.InnerException; 81 | } 82 | }, 83 | activities: new[] { 84 | ("Throw", TestService.MakeActivity((ctx, input) => throw exceptionToThrow)), 85 | }); 86 | 87 | OrchestrationState state = await instance.WaitForCompletion(); 88 | 89 | Assert.NotNull(state.Output); 90 | 91 | // The output should be a serialized ApplicationException. 92 | // NOTE: Need to specify TypeNameHandling.All to get the exception types to be honored. 93 | Exception caughtException = JsonConvert.DeserializeObject( 94 | state.Output, 95 | new JsonSerializerSettings { TypeNameHandling = TypeNameHandling.All }); 96 | Assert.NotNull(caughtException); 97 | Assert.Equal(exceptionToThrow.Message, caughtException.Message); 98 | Assert.IsType(caughtException); 99 | 100 | // Check that the inner exception was correctly preserved. 101 | Assert.NotNull(caughtException.InnerException); 102 | Exception caughtInnerException = Assert.IsType(caughtException.InnerException); 103 | Assert.Equal(innerException.Message, caughtInnerException.Message); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.Tests/Integration/StressTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.Tests.Integration 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Threading.Tasks; 10 | using DurableTask.SqlServer.Tests.Utils; 11 | using Xunit; 12 | using Xunit.Abstractions; 13 | 14 | /// 15 | /// Integration tests that are intended to reveal issues related to load or concurrency. 16 | /// These tests are expected to take longer to complete compared to functional integration 17 | /// tests and therefore may not be appropriate for all CI or rapid testing scenarios. 18 | /// 19 | [Trait("Category", "Stress")] 20 | [Collection("Integration")] 21 | public class StressTests : IAsyncLifetime 22 | { 23 | readonly TestService testService; 24 | 25 | public StressTests(ITestOutputHelper output) 26 | { 27 | this.testService = new TestService(output); 28 | } 29 | 30 | Task IAsyncLifetime.InitializeAsync() => this.testService.InitializeAsync(); 31 | 32 | Task IAsyncLifetime.DisposeAsync() => this.testService.DisposeAsync(); 33 | 34 | // This test has previously been used to uncover various deadlock issues by stressing the code paths 35 | // related to foreign keys that point to the Instances and Payloads tables. 36 | // Example: https://github.com/microsoft/durabletask-mssql/issues/45 37 | [Theory(Timeout = 1_000_000)] 38 | [InlineData(10)] 39 | [InlineData(2000)] 40 | public async Task ParallelSubOrchestrations(int subOrchestrationCount) 41 | { 42 | const string SubOrchestrationName = "SubOrchestration"; 43 | 44 | this.testService.RegisterInlineOrchestration( 45 | orchestrationName: SubOrchestrationName, 46 | version: "", 47 | implementation: async (ctx, input) => 48 | { 49 | await ctx.CreateTimer(DateTime.MinValue, input); 50 | return ctx.CurrentUtcDateTime; 51 | }); 52 | 53 | TestInstance testInstance = await this.testService.RunOrchestration( 54 | input: 1, 55 | orchestrationName: nameof(ParallelSubOrchestrations), 56 | implementation: async (ctx, input) => 57 | { 58 | var listInstances = new List>(); 59 | for (int i = 0; i < subOrchestrationCount; i++) 60 | { 61 | Task instance = ctx.CreateSubOrchestrationInstance( 62 | name: SubOrchestrationName, 63 | version: "", 64 | instanceId: $"suborchestration[{i}]", 65 | input: $"{i}"); 66 | listInstances.Add(instance); 67 | } 68 | 69 | DateTime[] results = await Task.WhenAll(listInstances); 70 | return new List(results); 71 | }); 72 | 73 | // On a fast Windows desktop machine, a 2000 sub-orchestration test should complete in 30-40 seconds. 74 | // On slower CI machines, this test could take several minutes to complete. 75 | await testInstance.WaitForCompletion(TimeSpan.FromMinutes(5)); 76 | } 77 | 78 | [Theory(Timeout = 100_000)] 79 | [InlineData(10)] 80 | public async Task ParallelWithBigPayload(int subOrchestrationCount) 81 | { 82 | const string SubOrchestrationName = "SubOrchestration"; 83 | string bigString = string.Join("", Enumerable.Range(0, 1024 * 1024 * 10).Select(x => "1")); 84 | 85 | this.testService.RegisterInlineOrchestration( 86 | orchestrationName: SubOrchestrationName, 87 | version: "", 88 | implementation: async (ctx, input) => 89 | { 90 | await ctx.CreateTimer(DateTime.MinValue, input); 91 | return ctx.CurrentUtcDateTime; 92 | }); 93 | 94 | TestInstance testInstance = await this.testService.RunOrchestration( 95 | input: 1, 96 | orchestrationName: nameof(ParallelSubOrchestrations), 97 | implementation: async (ctx, input) => 98 | { 99 | var listInstances = new List>(); 100 | for (int i = 0; i < subOrchestrationCount; i++) 101 | { 102 | Task instance = ctx.CreateSubOrchestrationInstance( 103 | name: SubOrchestrationName, 104 | version: "", 105 | instanceId: $"suborchestration[{i}]", 106 | input: $"{i}-{bigString}"); 107 | listInstances.Add(instance); 108 | } 109 | 110 | DateTime[] results = await Task.WhenAll(listInstances); 111 | return new List(results); 112 | }); 113 | 114 | await testInstance.WaitForCompletion(TimeSpan.FromMinutes(1)); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.Tests/Logging/LogAssertExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.Tests.Logging 5 | { 6 | using System.Collections.Generic; 7 | using System.Linq; 8 | using DurableTask.SqlServer.Logging; 9 | using Xunit; 10 | 11 | static class LogAssertExtensions 12 | { 13 | public static void AllowAdditionalLogs(this IEnumerable logs) => 14 | logs.ToList(); // Resolve the enumerable 15 | 16 | public static void EndOfLog(this IEnumerable logs) 17 | { 18 | int count = logs.Count(); 19 | Assert.True(count == 0, $"Expected no additional logs but found {count}."); 20 | } 21 | 22 | public static IEnumerable Contains(this IEnumerable logs, params LogAssert[] asserts) 23 | => logs.Contains(optional: false, asserts: asserts); 24 | 25 | public static IEnumerable ContainsIf(this IEnumerable logs, bool predicate, params LogAssert[] asserts) => 26 | predicate ? logs.Contains(asserts) : logs; 27 | 28 | public static IEnumerable Expect(this IEnumerable logs, params LogAssert[] asserts) 29 | { 30 | int i = 0; 31 | foreach (LogEntry actual in logs) 32 | { 33 | if (actual.EventId == EventIds.GenericInfo) 34 | { 35 | // Ignore generic info events, which can be non-deterministic 36 | continue; 37 | } 38 | 39 | if (asserts.Length > i) 40 | { 41 | LogAssert expected = asserts[i++]; 42 | 43 | // GenericInfo logs are not supported for validation 44 | Assert.NotEqual(EventIds.GenericInfo, expected.EventId); 45 | 46 | Assert.Equal(expected.EventName, actual.EventId.Name); 47 | Assert.Equal(expected.EventId, actual.EventId.Id); 48 | Assert.Equal(expected.Level, actual.LogLevel); 49 | Assert.Contains(expected.MessageSubstring, actual.Message); 50 | 51 | LogAssert.ValidateStructuredLogFields(actual); 52 | } 53 | else 54 | { 55 | yield return actual; 56 | } 57 | } 58 | } 59 | 60 | public static IEnumerable ExpectIf(this IEnumerable logs, bool predicate, params LogAssert[] asserts) => 61 | predicate ? logs.Expect(asserts) : logs; 62 | 63 | public static IEnumerable OptionallyContainsIf(this IEnumerable logs, bool predicate, params LogAssert[] asserts) => 64 | predicate ? logs.Contains(optional: true, asserts: asserts) : logs; 65 | 66 | static IEnumerable Contains(this IEnumerable logs, bool optional, params LogAssert[] asserts) 67 | { 68 | var remaining = new HashSet(asserts); 69 | 70 | foreach (LogEntry logEntry in logs) 71 | { 72 | LogAssert match = remaining.FirstOrDefault(assert => 73 | string.Equals(assert.EventName, logEntry.EventId.Name) && 74 | assert.EventId == logEntry.EventId.Id && 75 | assert.Level == logEntry.LogLevel && 76 | logEntry.Message.Contains(assert.MessageSubstring)); 77 | 78 | if (match != null) 79 | { 80 | remaining.Remove(match); 81 | } 82 | else 83 | { 84 | yield return logEntry; 85 | } 86 | } 87 | 88 | if (!optional) 89 | { 90 | Assert.Empty(remaining); 91 | } 92 | } 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.Tests/Logging/LogEntry.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.Tests.Logging 5 | { 6 | using System; 7 | using Microsoft.Extensions.Logging; 8 | 9 | public class LogEntry 10 | { 11 | public LogEntry( 12 | string category, 13 | LogLevel level, 14 | EventId eventId, 15 | Exception exception, 16 | string message, 17 | object state) 18 | { 19 | this.Category = category; 20 | this.LogLevel = level; 21 | this.EventId = eventId; 22 | this.Exception = exception; 23 | this.Message = message; 24 | this.Timestamp = DateTime.Now; 25 | this.State = state; 26 | } 27 | 28 | public string Category { get; } 29 | 30 | public DateTime Timestamp { get; } 31 | 32 | public EventId EventId { get; } 33 | 34 | public LogLevel LogLevel { get; } 35 | 36 | public Exception Exception { get; } 37 | 38 | public string Message { get; } 39 | 40 | public object State { get; } 41 | 42 | public override string ToString() 43 | { 44 | string output = $"{this.Timestamp:o} [{this.Category}] {this.Message}"; 45 | if (this.Exception != null) 46 | { 47 | output += Environment.NewLine + this.Exception.ToString(); 48 | } 49 | 50 | return output; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.Tests/Logging/TestLogProvider.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.Tests.Logging 5 | { 6 | using System; 7 | using System.Collections.Concurrent; 8 | using System.Collections.Generic; 9 | using System.Linq; 10 | using Microsoft.Extensions.Logging; 11 | using Xunit.Abstractions; 12 | 13 | public sealed class TestLogProvider : ILoggerProvider 14 | { 15 | readonly ITestOutputHelper output; 16 | readonly ConcurrentDictionary loggers; 17 | 18 | public TestLogProvider(ITestOutputHelper output) 19 | { 20 | this.output = output ?? throw new ArgumentNullException(nameof(output)); 21 | this.loggers = new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase); 22 | } 23 | 24 | public bool TryGetLogs(string category, out IReadOnlyCollection logs) 25 | { 26 | if (this.loggers.TryGetValue(category, out TestLogger logger)) 27 | { 28 | logs = logger.GetLogs(); 29 | return true; 30 | } 31 | 32 | logs = Array.Empty(); 33 | return false; 34 | } 35 | 36 | public void Clear() 37 | { 38 | foreach (TestLogger logger in this.loggers.Values.OfType()) 39 | { 40 | logger.ClearLogs(); 41 | } 42 | } 43 | 44 | ILogger ILoggerProvider.CreateLogger(string categoryName) 45 | { 46 | return this.loggers.GetOrAdd(categoryName, _ => new TestLogger(categoryName, this.output)); 47 | } 48 | 49 | void IDisposable.Dispose() 50 | { 51 | // no-op 52 | } 53 | 54 | class TestLogger : ILogger 55 | { 56 | readonly string category; 57 | readonly ITestOutputHelper output; 58 | readonly ConcurrentQueue entries; 59 | 60 | public TestLogger(string category, ITestOutputHelper output) 61 | { 62 | this.category = category; 63 | this.output = output; 64 | this.entries = new ConcurrentQueue(); 65 | } 66 | 67 | public IReadOnlyCollection GetLogs() => this.entries; 68 | 69 | public void ClearLogs() => this.entries.Clear(); 70 | 71 | IDisposable ILogger.BeginScope(TState state) => null; 72 | 73 | bool ILogger.IsEnabled(LogLevel logLevel) => true; 74 | 75 | void ILogger.Log( 76 | LogLevel level, 77 | EventId eventId, 78 | TState state, 79 | Exception exception, 80 | Func formatter) 81 | { 82 | var entry = new LogEntry( 83 | this.category, 84 | level, 85 | eventId, 86 | exception, 87 | formatter(state, exception), 88 | state); 89 | this.entries.Enqueue(entry); 90 | 91 | try 92 | { 93 | this.output.WriteLine(entry.ToString()); 94 | } 95 | catch (InvalidOperationException) 96 | { 97 | // Expected when tests are shutting down 98 | } 99 | } 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.Tests/Unit/SqlIdentifierTests.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.Tests.Unit 5 | { 6 | using Xunit; 7 | 8 | public class SqlIdentifierTests 9 | { 10 | [Theory] 11 | [InlineData(" ", "[ ]")] 12 | [InlineData("foo", "[foo]")] 13 | [InlineData("foo]bar", "[foo]]bar]")] 14 | [InlineData("foo\"bar", "[foo\"bar]")] 15 | [InlineData("𐊗𐊕𐊐𐊎𐊆𐊍𐊆", "[𐊗𐊕𐊐𐊎𐊆𐊍𐊆]")] 16 | [InlineData("DurableDB; DROP TABLE ImportantData", "[DurableDB; DROP TABLE ImportantData]")] 17 | public void EscapeSqlIdentifiers(string input, string expected) 18 | { 19 | Assert.Equal(expected, SqlIdentifier.Escape(input)); 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.Tests/Utils/TestCredential.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.Tests.Utils 5 | { 6 | public class TestCredential 7 | { 8 | public TestCredential(string userId, string connectionString) 9 | { 10 | this.UserId = userId; 11 | this.ConnectionString = connectionString; 12 | } 13 | 14 | public string UserId { get; } 15 | 16 | public string ConnectionString { get; } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /test/DurableTask.SqlServer.Tests/Utils/TestInstance.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace DurableTask.SqlServer.Tests.Utils 5 | { 6 | using System; 7 | using System.Diagnostics; 8 | using System.Threading.Tasks; 9 | using DurableTask.Core; 10 | using Newtonsoft.Json; 11 | using Newtonsoft.Json.Linq; 12 | using Xunit; 13 | 14 | class TestInstance 15 | { 16 | readonly TaskHubClient client; 17 | readonly OrchestrationInstance instance; 18 | readonly string name; 19 | readonly string version; 20 | 21 | DateTime startTime; 22 | TInput input; 23 | 24 | public TestInstance( 25 | TaskHubClient client, 26 | OrchestrationInstance instance, 27 | string name, 28 | string version, 29 | DateTime startTime, 30 | TInput input) 31 | { 32 | this.client = client; 33 | this.instance = instance; 34 | this.name = name; 35 | this.version = version; 36 | this.startTime = startTime; 37 | this.input = input; 38 | } 39 | 40 | public string InstanceId => this.instance?.InstanceId; 41 | 42 | public string ExecutionId => this.instance?.ExecutionId; 43 | 44 | OrchestrationInstance GetInstanceForAnyExecution() => new OrchestrationInstance 45 | { 46 | InstanceId = this.instance.InstanceId, 47 | }; 48 | 49 | public async Task WaitForStart(TimeSpan timeout = default) 50 | { 51 | AdjustTimeout(ref timeout); 52 | 53 | Stopwatch sw = Stopwatch.StartNew(); 54 | do 55 | { 56 | OrchestrationState state = await this.GetStateAsync(); 57 | if (state != null && state.OrchestrationStatus != OrchestrationStatus.Pending) 58 | { 59 | return state; 60 | } 61 | 62 | await Task.Delay(TimeSpan.FromMilliseconds(500)); 63 | 64 | } while (sw.Elapsed < timeout); 65 | 66 | throw new TimeoutException($"Orchestration with instance ID '{this.instance.InstanceId}' failed to start."); 67 | } 68 | 69 | public async Task WaitForCompletion( 70 | TimeSpan timeout = default, 71 | OrchestrationStatus expectedStatus = OrchestrationStatus.Completed, 72 | object expectedOutput = null, 73 | string expectedOutputRegex = null, 74 | bool continuedAsNew = false, 75 | bool doNotAdjustTimeout = false) 76 | { 77 | if (!doNotAdjustTimeout) 78 | { 79 | AdjustTimeout(ref timeout); 80 | } 81 | 82 | OrchestrationState state = await this.client.WaitForOrchestrationAsync(this.GetInstanceForAnyExecution(), timeout); 83 | Assert.NotNull(state); 84 | Assert.Equal(expectedStatus, state.OrchestrationStatus); 85 | 86 | if (!continuedAsNew) 87 | { 88 | if (this.input != null) 89 | { 90 | Assert.Equal(JToken.FromObject(this.input).ToString(), JToken.Parse(state.Input).ToString()); 91 | } 92 | else 93 | { 94 | Assert.Null(state.Input); 95 | } 96 | } 97 | 98 | // For created time, account for potential clock skew 99 | Assert.True(state.CreatedTime >= this.startTime.AddMinutes(-5)); 100 | Assert.True(state.LastUpdatedTime > state.CreatedTime); 101 | Assert.True(state.CompletedTime > state.CreatedTime); 102 | Assert.NotNull(state.OrchestrationInstance); 103 | Assert.Equal(this.instance.InstanceId, state.OrchestrationInstance.InstanceId); 104 | 105 | // Make sure there is an ExecutionId, but don't require it to match any particular value 106 | Assert.NotNull(state.OrchestrationInstance.ExecutionId); 107 | 108 | if (expectedOutput != null) 109 | { 110 | Assert.NotNull(state.Output); 111 | try 112 | { 113 | // DTFx usually encodes outputs as JSON values. The exception is error messages. 114 | // If this is an error message, we'll throw here and try the logic in the catch block. 115 | JToken.Parse(state.Output); 116 | Assert.Equal(JToken.FromObject(expectedOutput).ToString(Formatting.None), state.Output); 117 | } 118 | catch (JsonReaderException) 119 | { 120 | Assert.Equal(expectedOutput, state?.Output); 121 | } 122 | } 123 | 124 | if (expectedOutputRegex != null) 125 | { 126 | Assert.Matches(expectedOutputRegex, state.Output); 127 | } 128 | 129 | return state; 130 | } 131 | 132 | internal Task GetStateAsync() 133 | { 134 | return this.client.GetOrchestrationStateAsync(new OrchestrationInstance 135 | { 136 | InstanceId = this.instance.InstanceId, 137 | }); 138 | } 139 | 140 | internal Task RaiseEventAsync(string name, object value) 141 | { 142 | return this.client.RaiseEventAsync(this.instance, name, value); 143 | } 144 | 145 | internal Task TerminateAsync(string reason) 146 | { 147 | return this.client.TerminateInstanceAsync(this.instance, reason); 148 | } 149 | 150 | internal async Task RestartAsync(TInput newInput, OrchestrationStatus[] dedupeStatuses = null) 151 | { 152 | OrchestrationInstance newInstance = await this.client.CreateOrchestrationInstanceAsync( 153 | this.name, 154 | this.version, 155 | this.InstanceId, 156 | newInput, 157 | tags: null, 158 | dedupeStatuses); 159 | 160 | this.input = newInput; 161 | this.startTime = DateTime.UtcNow; 162 | this.instance.ExecutionId = newInstance.ExecutionId; 163 | } 164 | 165 | 166 | internal Task SuspendAsync(string reason = null) 167 | { 168 | return this.client.SuspendInstanceAsync(this.instance, reason); 169 | } 170 | 171 | internal Task ResumeAsync(string reason = null) 172 | { 173 | return this.client.ResumeInstanceAsync(this.instance, reason); 174 | } 175 | 176 | static void AdjustTimeout(ref TimeSpan timeout) 177 | { 178 | timeout = timeout.AdjustForDebugging(); 179 | } 180 | } 181 | } 182 | -------------------------------------------------------------------------------- /test/PerformanceTests/.dockerignore: -------------------------------------------------------------------------------- 1 | local.settings.json -------------------------------------------------------------------------------- /test/PerformanceTests/Common.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace PerformanceTests 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Threading; 10 | using System.Threading.Tasks; 11 | using Microsoft.AspNetCore.Http; 12 | using Microsoft.Azure.WebJobs; 13 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 14 | using Microsoft.Extensions.Logging; 15 | 16 | static class Common 17 | { 18 | public static async Task ScheduleManyInstances( 19 | IDurableOrchestrationClient client, 20 | ILogger log, 21 | string orchestrationName, 22 | int count, 23 | string prefix) 24 | { 25 | DateTime utcNow = DateTime.UtcNow; 26 | prefix += utcNow.ToString("yyyyMMdd-hhmmss"); 27 | 28 | log.LogWarning($"Scheduling {count} orchestration(s) with a prefix of '{prefix}'..."); 29 | 30 | await Enumerable.Range(0, count).ParallelForEachAsync(200, i => 31 | { 32 | string instanceId = $"{prefix}-{i:X16}"; 33 | return client.StartNewAsync(orchestrationName, instanceId); 34 | }); 35 | 36 | log.LogWarning($"All {count} orchestrations were scheduled successfully!"); 37 | return prefix; 38 | } 39 | 40 | [FunctionName(nameof(SayHello))] 41 | public static string SayHello([ActivityTrigger] string name, string instanceId, ILogger logger) 42 | { 43 | logger.LogInformation("Hello from {city} - {id}", name, instanceId); 44 | return $"Hello {name}!"; 45 | } 46 | 47 | public static bool TryGetPositiveIntQueryStringParam(this HttpRequest req, string name, out int value) 48 | { 49 | return int.TryParse(req.Query[name], out value) && value > 0; 50 | } 51 | 52 | public static async Task ParallelForEachAsync(this IEnumerable items, int maxConcurrency, Func action) 53 | { 54 | List tasks; 55 | if (items is ICollection itemCollection) 56 | { 57 | tasks = new List(itemCollection.Count); 58 | } 59 | else 60 | { 61 | tasks = new List(); 62 | } 63 | 64 | using var semaphore = new SemaphoreSlim(maxConcurrency); 65 | foreach (T item in items) 66 | { 67 | tasks.Add(InvokeThrottledAction(item, action, semaphore)); 68 | } 69 | 70 | await Task.WhenAll(tasks); 71 | } 72 | 73 | static async Task InvokeThrottledAction(T item, Func action, SemaphoreSlim semaphore) 74 | { 75 | await semaphore.WaitAsync(); 76 | try 77 | { 78 | await action(item); 79 | } 80 | finally 81 | { 82 | semaphore.Release(); 83 | } 84 | } 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /test/PerformanceTests/Dockerfile: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | 4 | FROM mcr.microsoft.com/dotnet/sdk:6.0 AS installer-env 5 | 6 | COPY . /durabletask-mssql 7 | RUN cd /durabletask-mssql/test/PerformanceTests && \ 8 | mkdir -p /home/site/wwwroot && \ 9 | dotnet publish -c Release *.csproj --output /home/site/wwwroot 10 | 11 | # To enable ssh & remote debugging on app service change the base image to the one below 12 | # FROM mcr.microsoft.com/azure-functions/dotnet:3.0-appservice 13 | FROM mcr.microsoft.com/azure-functions/dotnet:4 14 | ENV AzureWebJobsScriptRoot=/home/site/wwwroot \ 15 | AzureFunctionsJobHost__Logging__Console__IsEnabled=true 16 | 17 | COPY --from=installer-env ["/home/site/wwwroot", "/home/site/wwwroot"] 18 | -------------------------------------------------------------------------------- /test/PerformanceTests/LongHaul.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace PerformanceTests 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Linq; 9 | using System.Text.Json; 10 | using System.Threading; 11 | using System.Threading.Tasks; 12 | using Microsoft.AspNetCore.Http; 13 | using Microsoft.AspNetCore.Mvc; 14 | using Microsoft.Azure.WebJobs; 15 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 16 | using Microsoft.Azure.WebJobs.Extensions.Http; 17 | using Microsoft.Extensions.Logging; 18 | 19 | public class LongHaul 20 | { 21 | // HTTPie command: 22 | // http post http://localhost:7071/api/StartLongHaul TotalHours:=1 OrchestrationsPerInterval:=1000 Interval=00:05:00 23 | [FunctionName(nameof(StartLongHaul))] 24 | public static async Task StartLongHaul( 25 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, 26 | [DurableClient] IDurableClient starter, 27 | ILogger log) 28 | { 29 | string input = await req.ReadAsStringAsync(); 30 | 31 | LongHaulOptions options = null; 32 | if (!string.IsNullOrEmpty(input)) 33 | { 34 | try 35 | { 36 | options = JsonSerializer.Deserialize(input); 37 | } 38 | catch (JsonException e) 39 | { 40 | log.LogWarning(e, "Received bad JSON input"); 41 | } 42 | } 43 | 44 | if (options == null || !options.IsValid()) 45 | { 46 | return new BadRequestObjectResult(new 47 | { 48 | error = "Required request content is missing or invalid", 49 | usage = new SortedDictionary 50 | { 51 | [nameof(LongHaulOptions.TotalHours)] = "The total length of time the test should run. Example: '72' for 72 hours.", 52 | [nameof(LongHaulOptions.OrchestrationsPerInterval)] = "The number of orchestrations to schedule per interval. Example: '1000' to schedule 1,000 every interval.", 53 | [nameof(LongHaulOptions.Interval)] = "The frequency for scheduling orchestration batches. Example: '00:05:00' for 5 minutes.", 54 | }, 55 | }); 56 | } 57 | 58 | // Instance ID contains the timestamp and the configuration parameters for easier searching and categorization 59 | string instanceId = $"longhaul_{DateTime.UtcNow:yyyyMMddHHmmss}_{options.TotalHours}_{options.OrchestrationsPerInterval}_{(int)options.Interval.TotalSeconds}"; 60 | await starter.StartNewAsync( 61 | nameof(LongHaulOrchestrator), 62 | instanceId, 63 | new LongHaulState 64 | { 65 | Options = options, 66 | Deadline = DateTime.UtcNow.AddHours(options.TotalHours), 67 | }); 68 | 69 | log.LogWarning("Started long-haul orchestrator with ID = {instanceId}", instanceId); 70 | return starter.CreateCheckStatusResponse(req, instanceId); 71 | } 72 | 73 | /// 74 | /// Long-running orchestration that schedules bursts of shorter "Hello cities" orchestrations to run on a given interval 75 | /// 76 | [FunctionName(nameof(LongHaulOrchestrator))] 77 | public static async Task LongHaulOrchestrator( 78 | [OrchestrationTrigger] IDurableOrchestrationContext context, 79 | ILogger logger) 80 | { 81 | LongHaulState state = context.GetInput(); 82 | if (context.CurrentUtcDateTime > state.Deadline) 83 | { 84 | return; 85 | } 86 | 87 | state.Iteration++; 88 | int currentTotal = state.TotalOrchestrationsCompleted; 89 | 90 | // Schedule all orchestrations in parallel 91 | List tasks = Enumerable 92 | .Range(0, state.Options.OrchestrationsPerInterval) 93 | .Select(i => 94 | { 95 | // Each sub-orchestration should have a unique ID 96 | int suffix = state.TotalOrchestrationsCompleted + i; 97 | return context.CallSubOrchestratorAsync( 98 | nameof(ManySequences.HelloCities), 99 | instanceId: $"{context.InstanceId}_{suffix:X8}", 100 | input: null); 101 | }) 102 | .ToList(); 103 | 104 | context.SetCustomStatus(state); 105 | 106 | // Wait for all sub-orchestrations to complete 107 | await Task.WhenAll(tasks); 108 | 109 | state.TotalOrchestrationsCompleted += tasks.Count; 110 | context.SetCustomStatus(state); 111 | 112 | DateTime nextRunTime = context.CurrentUtcDateTime.Add(state.Options.Interval); 113 | await context.CreateTimer(nextRunTime, CancellationToken.None); 114 | 115 | context.ContinueAsNew(state); 116 | } 117 | 118 | /// 119 | /// Options for starting this orchestration 120 | /// 121 | class LongHaulOptions 122 | { 123 | [Newtonsoft.Json.JsonProperty] 124 | public int TotalHours { get; set; } 125 | [Newtonsoft.Json.JsonProperty] 126 | public int OrchestrationsPerInterval { get; set; } 127 | [Newtonsoft.Json.JsonProperty] 128 | public TimeSpan Interval { get; set; } 129 | 130 | public bool IsValid() => 131 | this.TotalHours > 0 && 132 | this.OrchestrationsPerInterval > 0 && 133 | this.Interval > TimeSpan.Zero; 134 | } 135 | 136 | /// 137 | /// State maintained by the long-haul orchestration 138 | /// 139 | class LongHaulState 140 | { 141 | [Newtonsoft.Json.JsonProperty] 142 | public LongHaulOptions Options { get; set; } 143 | 144 | [Newtonsoft.Json.JsonProperty] 145 | public DateTime Deadline { get; set; } 146 | 147 | [Newtonsoft.Json.JsonProperty(DefaultValueHandling = Newtonsoft.Json.DefaultValueHandling.Ignore)] 148 | public int TotalOrchestrationsCompleted { get; set; } 149 | 150 | [Newtonsoft.Json.JsonProperty(DefaultValueHandling = Newtonsoft.Json.DefaultValueHandling.Ignore)] 151 | public int Iteration { get; set; } 152 | } 153 | } 154 | } 155 | -------------------------------------------------------------------------------- /test/PerformanceTests/ManyEntities.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace PerformanceTests 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Threading.Tasks; 9 | using Microsoft.AspNetCore.Http; 10 | using Microsoft.AspNetCore.Mvc; 11 | using Microsoft.Azure.WebJobs; 12 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 13 | using Microsoft.Azure.WebJobs.Extensions.Http; 14 | using Microsoft.Extensions.Logging; 15 | using Newtonsoft.Json; 16 | 17 | class ManyEntities 18 | { 19 | const string EntityName = "Counter"; 20 | 21 | [JsonProperty("count")] 22 | public int CurrentValue { get; set; } 23 | 24 | public void Add(int amount) => this.CurrentValue += amount; 25 | 26 | public void Reset() => this.CurrentValue = 0; 27 | 28 | public int Get() => this.CurrentValue; 29 | 30 | #pragma warning disable DF0305 // Entity function name must match an existing entity class name. 31 | [FunctionName(EntityName)] 32 | #pragma warning restore DF0305 // Entity function name must match an existing entity class name. 33 | #pragma warning disable DF0307 // DispatchAsync must be used with the entity name, equal to the name of the function it's used in. 34 | public static Task Run([EntityTrigger] IDurableEntityContext ctx) => ctx.DispatchAsync(); 35 | #pragma warning restore DF0307 // DispatchAsync must be used with the entity name, equal to the name of the function it's used in. 36 | 37 | [FunctionName(nameof(StartManyEntities))] 38 | public static async Task StartManyEntities( 39 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, 40 | [DurableClient] IDurableClient client, 41 | ILogger log) 42 | { 43 | log.LogInformation("C# HTTP trigger function processed a request."); 44 | 45 | if (!req.TryGetPositiveIntQueryStringParam("entities", out int entities) || 46 | !req.TryGetPositiveIntQueryStringParam("messages", out int messages)) 47 | { 48 | return new BadRequestObjectResult(new 49 | { 50 | error = "Required query string parameters are missing", 51 | usage = new 52 | { 53 | entities = "The number of entities to create", 54 | messages = "The number of messages to send to each entity", 55 | }, 56 | }); 57 | } 58 | 59 | DateTime utcNow = DateTime.UtcNow; 60 | string prefix = utcNow.ToString("yyyyMMdd-hhmmss"); 61 | 62 | log.LogWarning($"Sending {messages} events to {entities} entities..."); 63 | 64 | var tasks = new List(messages * entities); 65 | for (int i = 0; i < messages; i++) 66 | { 67 | for (int j = 0; j < entities; j++) 68 | { 69 | var entityId = new EntityId(EntityName, $"{prefix}-{j:X16}"); 70 | tasks.Add(client.SignalEntityAsync(entityId, "add", 1)); 71 | } 72 | } 73 | 74 | await Task.WhenAll(tasks); 75 | 76 | return new OkObjectResult($"Sent {messages} events to {entities} {EntityName} entities prefixed with '{prefix}'."); 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /test/PerformanceTests/ManyMixedOrchestrations.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace PerformanceTests 5 | { 6 | using System; 7 | using System.Threading.Tasks; 8 | using Microsoft.AspNetCore.Http; 9 | using Microsoft.AspNetCore.Mvc; 10 | using Microsoft.Azure.WebJobs; 11 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 12 | using Microsoft.Azure.WebJobs.Extensions.Http; 13 | using Microsoft.Extensions.Logging; 14 | 15 | class ManyMixedOrchestrations 16 | { 17 | [FunctionName(nameof(StartManyMixedOrchestrations))] 18 | public static async Task StartManyMixedOrchestrations( 19 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, 20 | [DurableClient] IDurableClient starter, 21 | ILogger log) 22 | { 23 | if (!int.TryParse(req.Query["count"], out int count) || count < 1) 24 | { 25 | return new BadRequestObjectResult("A 'count' query string parameter is required and it must contain a positive number."); 26 | } 27 | 28 | string initialPrefix = (string)req.Query["prefix"] ?? string.Empty; 29 | 30 | string finalPrefix = await Common.ScheduleManyInstances(starter, log, nameof(MixedOrchestration), count, initialPrefix); 31 | return new OkObjectResult($"Scheduled {count} orchestrations prefixed with '{finalPrefix}'."); 32 | } 33 | 34 | [FunctionName(nameof(MixedOrchestration))] 35 | public static async Task MixedOrchestration( 36 | [OrchestrationTrigger] IDurableOrchestrationContext context) 37 | { 38 | // Flow: 39 | // 1. Call an activity function 40 | // 2. Start a sub-orchestration 41 | // 3. Wait for an external event w/a timer + sub-orchestration completion 42 | // 4. Call an activity with a retry policy and catch the exception 43 | await context.CallActivityAsync(nameof(Common.SayHello), "World"); 44 | 45 | string callbackEventName = "CallbackEvent"; 46 | Task subOrchestration = context.CallSubOrchestratorAsync( 47 | nameof(CallMeBack), 48 | context.InstanceId + "-sub", 49 | (context.InstanceId, callbackEventName)); 50 | 51 | Task onCalledBack = context.WaitForExternalEvent(callbackEventName, TimeSpan.FromMinutes(1)); 52 | 53 | await Task.WhenAll(subOrchestration, onCalledBack); 54 | 55 | try 56 | { 57 | await context.CallActivityWithRetryAsync( 58 | nameof(Throw), 59 | new RetryOptions(TimeSpan.FromSeconds(5), 2), 60 | null); 61 | } 62 | catch (FunctionFailedException) 63 | { 64 | // no-op 65 | } 66 | } 67 | 68 | [FunctionName(nameof(CallMeBack))] 69 | public static Task CallMeBack([OrchestrationTrigger] IDurableOrchestrationContext context) 70 | { 71 | (string callbackInstance, string eventName) = context.GetInput<(string, string)>(); 72 | return context.CallActivityAsync( 73 | nameof(RaiseEvent), 74 | input: (callbackInstance, eventName)); 75 | } 76 | 77 | [FunctionName(nameof(RaiseEvent))] 78 | public static Task RaiseEvent( 79 | [ActivityTrigger] (string instanceId, string eventName) input, 80 | [DurableClient] IDurableClient client) 81 | { 82 | return client.RaiseEventAsync(input.instanceId, input.eventName); 83 | } 84 | 85 | [FunctionName(nameof(Throw))] 86 | public static void Throw([ActivityTrigger] IDurableActivityContext ctx) => throw new Exception("Kah-BOOOOM!!!"); 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /test/PerformanceTests/ManySequences.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace PerformanceTests 5 | { 6 | using System; 7 | using System.Collections.Generic; 8 | using System.Diagnostics; 9 | using System.Threading.Tasks; 10 | using Microsoft.AspNetCore.Http; 11 | using Microsoft.AspNetCore.Mvc; 12 | using Microsoft.Azure.WebJobs; 13 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 14 | using Microsoft.Azure.WebJobs.Extensions.Http; 15 | using Microsoft.Extensions.Logging; 16 | 17 | public static class ManySequences 18 | { 19 | [FunctionName(nameof(StartManySequences))] 20 | public static async Task StartManySequences( 21 | [HttpTrigger(AuthorizationLevel.Anonymous, "post", Route = null)] HttpRequest req, 22 | [DurableClient] IDurableClient starter, 23 | ILogger log) 24 | { 25 | log.LogInformation("C# HTTP trigger function processed a request."); 26 | 27 | if (!int.TryParse(req.Query["count"], out int count) || count < 1) 28 | { 29 | return new BadRequestObjectResult("A 'count' query string parameter is required and it must contain a positive number."); 30 | } 31 | 32 | string initialPrefix = (string)req.Query["prefix"] ?? string.Empty; 33 | 34 | string finalPrefix = await Common.ScheduleManyInstances(starter, log, nameof(HelloCities), count, initialPrefix); 35 | return new OkObjectResult($"Scheduled {count} orchestrations prefixed with '{finalPrefix}'. ActivityId: {Activity.Current?.Id}"); 36 | } 37 | 38 | [FunctionName(nameof(HelloCities))] 39 | public static async Task> HelloCities( 40 | [OrchestrationTrigger] IDurableOrchestrationContext context, 41 | ILogger logger) 42 | { 43 | logger = context.CreateReplaySafeLogger(logger); 44 | logger.LogInformation("Starting '{name}' orchestration with ID = 'id'", context.Name, context.InstanceId); 45 | 46 | var outputs = new List 47 | { 48 | await context.CallActivityAsync(nameof(Common.SayHello), "Tokyo"), 49 | await context.CallActivityAsync(nameof(Common.SayHello), "Seattle"), 50 | await context.CallActivityAsync(nameof(Common.SayHello), "London"), 51 | await context.CallActivityAsync(nameof(Common.SayHello), "Amsterdam"), 52 | await context.CallActivityAsync(nameof(Common.SayHello), "Mumbai") 53 | }; 54 | 55 | logger.LogInformation("Finished '{name}' orchestration with ID = 'id'", context.Name, context.InstanceId); 56 | return outputs; 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /test/PerformanceTests/PerformanceTests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | net6.0 4 | v4 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | PreserveNewest 15 | 16 | 17 | PreserveNewest 18 | Never 19 | 20 | 21 | -------------------------------------------------------------------------------- /test/PerformanceTests/PurgeOrchestrationData.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace PerformanceTests 5 | { 6 | using System; 7 | using System.Diagnostics; 8 | using System.Threading.Tasks; 9 | using Microsoft.AspNetCore.Http; 10 | using Microsoft.AspNetCore.Mvc; 11 | using Microsoft.Azure.WebJobs; 12 | using Microsoft.Azure.WebJobs.Extensions.DurableTask; 13 | using Microsoft.Azure.WebJobs.Extensions.Http; 14 | using Microsoft.Extensions.Logging; 15 | 16 | public static class PurgeOrchestrationData 17 | { 18 | [FunctionName("PurgeOrchestrationData")] 19 | public static async Task Run( 20 | [HttpTrigger(AuthorizationLevel.Function, "post", Route = null)] HttpRequest req, 21 | [DurableClient] IDurableClient client, 22 | ILogger log) 23 | { 24 | log.LogWarning("Purging all orchestration data from the database"); 25 | 26 | int totalDeleted = 0; 27 | bool finished = false; 28 | 29 | // Stop after 25 seconds to avoid a client-side timeout 30 | var sw = Stopwatch.StartNew(); 31 | while (sw.Elapsed < TimeSpan.FromSeconds(25)) 32 | { 33 | // Purge all completed instances, dating back to the year 2000 34 | PurgeHistoryResult result = await client.PurgeInstanceHistoryAsync( 35 | createdTimeFrom: new DateTime(2000, 1, 1), 36 | createdTimeTo: null, 37 | runtimeStatus: null); 38 | 39 | totalDeleted += result.InstancesDeleted; 40 | 41 | // The SQL provider only deletes at most 1000 instances per call 42 | if (result.InstancesDeleted < 1000) 43 | { 44 | finished = true; 45 | break; 46 | } 47 | } 48 | 49 | log.LogWarning($"Purge of {totalDeleted} instance(s) completed after {sw.Elapsed}. Finished = {finished}."); 50 | 51 | return new OkObjectResult(new 52 | { 53 | totalDeleted, 54 | finished, 55 | }); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /test/PerformanceTests/Scripts/RunTestInAzure.ps1: -------------------------------------------------------------------------------- 1 | param( 2 | [string]$subscription, 3 | [string]$appName, 4 | [string]$planName, 5 | [string]$funcGroup, 6 | [string]$sqlGroup, 7 | [string]$functionSKU="EP2", 8 | [int]$instanceCount=4, 9 | [string]$sqlDbName, 10 | [string]$sqlDbServer, 11 | [string]$sqlComputeModel="Provisioned", # Provisioned, Serverless 12 | [int]$sqlMinCpus=2, 13 | [int]$sqlCPUs=2, # Gen5 options: 2, 4, 8, 16, 24, 32, 40, 64, 80 14 | [int]$count=5000 15 | ) 16 | 17 | $ErrorActionPreference = "Stop" 18 | 19 | # Installing the Azure CLI: https://docs.microsoft.com/cli/azure/install-azure-cli 20 | az account set -s $subscription 21 | 22 | # Update the SQL SKU 23 | # Reference: https://docs.microsoft.com/cli/azure/sql/db?view=azure-cli-latest#az_sql_db_update 24 | Write-Host "Setting $sqlDbServer/$sqlDbName to the $sqlComputeModel compute model with (or up to) $sqlCPUs vCPUs..." 25 | az sql db update --compute-model $sqlComputeModel --min-capacity $sqlMinCpus --capacity $sqlCPUs --family Gen5 --resource-group $sqlGroup --name $sqlDbName --server $sqlDbServer | Out-Null 26 | 27 | # Update the plan 28 | Write-Host "Setting $planName plan to max burst of $instanceCount instances..." 29 | 30 | # Update the app with a minimum instance count 31 | # NOTE: The order of these commands needs to change depending on whether we're adding or removing instances. 32 | # If adding, update the plan first. If subtracting, update the app first. 33 | az functionapp plan update --resource-group $funcGroup --name $planName --sku $functionSKU --min-instances $instanceCount --max-burst $instanceCount | Out-Null 34 | az resource update --resource-group $funcGroup --name "$appName/config/web" --set properties.minimumElasticInstanceCount=$instanceCount --resource-type "Microsoft.Web/sites" | Out-Null 35 | 36 | Write-Host "Hard-restarting the app to ensure any plan changes take effect" 37 | az functionapp stop --name $appName --resource-group $funcGroup 38 | Sleep 10 39 | az functionapp start --name $appName --resource-group $funcGroup 40 | Sleep 10 41 | 42 | $Stoploop = $false 43 | [int]$Retrycount = 0 44 | 45 | do { 46 | try { 47 | # ping the site to make sure it's up and running 48 | Write-Host "Pinging the app to ensure it can start-up" 49 | Invoke-RestMethod -Method Post -Uri "https://$appName.azurewebsites.net/admin/host/ping" 50 | $Stoploop = $true 51 | } 52 | catch { 53 | if ($Retrycount -gt 10){ 54 | Write-Host "The app is still down after 10 ping retries. Giving up." 55 | return 56 | } 57 | else { 58 | Write-Host "Ping failed, which means the app is down. Retrying in 60 seconds..." 59 | Start-Sleep -Seconds 60 60 | $Retrycount = $Retrycount + 1 61 | } 62 | } 63 | } 64 | While ($Stoploop -eq $false) 65 | 66 | # get the master key 67 | $masterKey = (az functionapp keys list --name $appName --resource-group $funcGroup --query "masterKey" --output tsv) 68 | 69 | # clear any data to make sure all tests start with the same amount of data in the database 70 | Write-Host "Purging database of old instances" 71 | Invoke-RestMethod -Method Post -Uri "https://$appName.azurewebsites.net/api/PurgeOrchestrationData?code=$masterKey" 72 | 73 | # The Invoke-RestMethod command seems to run asynchronously, so sleep to give it time to finish 74 | Write-Host "Sleeping for 15 seconds in case the previous command finished before the purge completed" 75 | Sleep 15 76 | 77 | # run the test with a prefix (example: "EP1-max1-sql4-10000") 78 | if ($sqlComputeModel -eq "Serverless") { 79 | $prefix = "$functionSKU-max$instanceCount-sqlServerless-$count-" 80 | } else { 81 | $prefix = "$functionSKU-max$instanceCount-sql$sqlCPUs-$count-" 82 | } 83 | Write-Host "Starting test with prefix '$prefix'..." 84 | $url = "https://$appName.azurewebsites.net/api/StartManySequences?count=$count&prefix=$prefix&code=$masterKey" 85 | Write-Host $url 86 | Invoke-RestMethod -Method Post -Uri $url -------------------------------------------------------------------------------- /test/PerformanceTests/host.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.0", 3 | "extensions": { 4 | "durableTask": { 5 | "maxConcurrentOrchestratorFunctions": 8, 6 | "maxConcurrentActivityFunctions": 8, 7 | "storageProvider": { 8 | "type": "mssql", 9 | "connectionStringName": "SQLDB_Connection", 10 | "createDatabaseIfNotExists": true 11 | } 12 | } 13 | }, 14 | "logging": { 15 | "logLevel": { 16 | "default": "Warning", 17 | "DurableTask.SqlServer": "Warning", 18 | "DurableTask.Core": "Warning" 19 | }, 20 | "applicationInsights": { 21 | "samplingSettings": { 22 | "isEnabled": false 23 | } 24 | } 25 | } 26 | } -------------------------------------------------------------------------------- /test/PerformanceTests/local.settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "IsEncrypted": false, 3 | "Values": { 4 | "FUNCTIONS_WORKER_RUNTIME": "dotnet", 5 | "SQLDB_Connection": "Server=localhost;Database=DurableDB;Trusted_Connection=True;", 6 | "AzureWebJobsSecretStorageType": "Files" 7 | } 8 | } -------------------------------------------------------------------------------- /test/setup.ps1: -------------------------------------------------------------------------------- 1 | # Copyright (c) Microsoft Corporation. 2 | # Licensed under the MIT License. 3 | 4 | param( 5 | [string]$pw="$env:SA_PASSWORD", 6 | [string]$sqlpid="Express", 7 | [string]$tag="2019-latest", 8 | [int]$port=1433, 9 | [string]$dbname="DurableDB", 10 | [string]$collation="Latin1_General_100_BIN2_UTF8" 11 | ) 12 | 13 | Write-Host "Pulling down the mcr.microsoft.com/mssql/server:$tag image..." 14 | docker pull mcr.microsoft.com/mssql/server:$tag 15 | 16 | # Start the SQL Server docker container with the specified edition 17 | Write-Host "Starting SQL Server $tag $sqlpid docker container on port $port" -ForegroundColor DarkYellow 18 | docker run --name mssql-server -e ACCEPT_EULA=Y -e "MSSQL_SA_PASSWORD=$pw" -e "MSSQL_PID=$sqlpid" -p ${port}:1433 -d mcr.microsoft.com/mssql/server:$tag 19 | 20 | if ($LASTEXITCODE -ne 0) { 21 | exit $LASTEXITCODE 22 | } 23 | 24 | # The container needs a bit more time before it can start accepting commands 25 | Write-Host "Sleeping for 30 seconds to let the container finish initializing..." -ForegroundColor Yellow 26 | Start-Sleep -Seconds 30 27 | 28 | # Check to see what containers are running 29 | docker ps 30 | 31 | # Create the database with strict binary collation 32 | Write-Host "Creating '$dbname' database with '$collation' collation" -ForegroundColor DarkYellow 33 | docker exec -d mssql-server /opt/mssql-tools18/bin/sqlcmd -S . -U sa -P "$pw" -Q "CREATE DATABASE [$dbname] COLLATE $collation" -------------------------------------------------------------------------------- /tools/TestDBGenerator/Program.cs: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT License. 3 | 4 | namespace TestDBGenerator; 5 | 6 | using System.IO.Compression; 7 | using DurableTask.SqlServer; 8 | using Microsoft.Data.SqlClient; 9 | using Microsoft.SqlServer.Management.Common; 10 | using Microsoft.SqlServer.Management.Smo; 11 | 12 | static class Program 13 | { 14 | static async Task Main(string[] args) 15 | { 16 | SqlConnectionStringBuilder builder = new(GetConnectionString()); 17 | Server dbServer = new(new ServerConnection(new SqlConnection(builder.ToString()))); 18 | 19 | // There's an assumption that the database schema version matches the assembly version. 20 | Version assemblyVersion = typeof(SqlOrchestrationService).Assembly.GetName().Version!; 21 | Version schemaVersion = new(assemblyVersion.Major, assemblyVersion.Minor, assemblyVersion.Build); 22 | 23 | string dbName = $"DurableDB-v{schemaVersion}"; 24 | 25 | Database db = new(dbServer, dbName); 26 | db.Create(); 27 | 28 | builder.InitialCatalog = db.Name; 29 | 30 | SqlOrchestrationServiceSettings settings = new(builder.ToString()); 31 | SqlOrchestrationService service = new(settings); 32 | await service.CreateAsync(); 33 | 34 | Console.WriteLine($"Created database '{db.Name}'."); 35 | 36 | Console.WriteLine($"Generating runtime data..."); 37 | await Orchestrations.GenerateRuntimeDataAsync(service); 38 | 39 | string backupLocation = Path.Join(Environment.CurrentDirectory, $"{db.Name}.bak"); 40 | Backup backup = new() 41 | { 42 | Database = db.Name, 43 | Action = BackupActionType.Database, 44 | CopyOnly = true, 45 | Incremental = false, 46 | SkipTapeHeader = true, 47 | UnloadTapeAfter = false, 48 | NoRewind = true, 49 | FormatMedia = true, 50 | Initialize = true, 51 | Devices = 52 | { 53 | new BackupDeviceItem(backupLocation, DeviceType.File), 54 | }, 55 | }; 56 | 57 | Console.WriteLine($"Backing up database to disk..."); 58 | backup.SqlBackup(dbServer); 59 | 60 | Console.WriteLine($"Created backup file '{backupLocation}'."); 61 | 62 | // Drop the original database so that it can be restored 63 | db.UserAccess = DatabaseUserAccess.Restricted; 64 | db.Alter(TerminationClause.RollbackTransactionsImmediately); 65 | db.Refresh(); 66 | db.Drop(); 67 | 68 | // Restore 69 | Restore restore = new() 70 | { 71 | Database = $"{db.Name}-restored", 72 | Devices = 73 | { 74 | new BackupDeviceItem(backupLocation, DeviceType.File), 75 | }, 76 | }; 77 | 78 | Console.WriteLine("Restoring database from file (for validation)..."); 79 | restore.SqlRestore(dbServer); 80 | 81 | Console.WriteLine($"Restored backup as '{db.Name}-restored"); 82 | 83 | Console.WriteLine("Compressing backup file..."); 84 | 85 | // Save to a zip file, which produces around 90% compression 86 | string zipFilePath = backupLocation + ".zip"; 87 | using (ZipArchive archive = ZipFile.Open(zipFilePath, ZipArchiveMode.Create)) 88 | { 89 | archive.CreateEntryFromFile(backupLocation, Path.GetFileName(backupLocation)); 90 | } 91 | 92 | Console.WriteLine("Generated zipped backup file: " + zipFilePath); 93 | 94 | // Delete the uncompressed file 95 | File.Delete(backupLocation); 96 | } 97 | 98 | static string GetConnectionString() 99 | { 100 | string? connectionString = Environment.GetEnvironmentVariable("SQLDB_Connection"); 101 | if (!string.IsNullOrEmpty(connectionString)) 102 | { 103 | return connectionString; 104 | } 105 | 106 | Console.Error.WriteLine("Specify the database connection string in the 'SQLDB_Connection' environment variable."); 107 | Environment.Exit(1); 108 | return string.Empty; 109 | } 110 | } -------------------------------------------------------------------------------- /tools/TestDBGenerator/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "TestDBGenerator": { 4 | "commandName": "Project", 5 | "environmentVariables": { 6 | "SQLDB_Connection": "Server=.;Trusted_Connection=True;Encrypt=False;" 7 | } 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /tools/TestDBGenerator/TestDBGenerator.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | Exe 5 | net6.0 6 | enable 7 | enable 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | --------------------------------------------------------------------------------