├── .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 |
4 |
5 |
6 |
7 | # Microsoft SQL Provider for the Durable Task Framework and Durable Functions
8 |
9 | [](https://github.com/microsoft/durabletask-mssql/actions?workflow=Build+and+Test)
10 | [](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 | [](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 | [](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 | [](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 |
--------------------------------------------------------------------------------