├── .github ├── dependabot.yml └── workflows │ ├── codeql-analysis.yml │ ├── dependency-review.yml │ ├── go.yml │ └── scorecards.yml ├── .gitignore ├── .pre-commit-config.yaml ├── CODE_OF_CONDUCT.md ├── LICENSE ├── README.md ├── SECURITY.md ├── SUPPORT.md ├── error.go ├── go.mod ├── go.sum ├── graph ├── error.go ├── go.mod ├── go.sum ├── graph.go ├── graph_test.go └── template.go ├── job_definition.go ├── job_instance.go ├── job_result.go ├── job_test.go ├── media └── asyncjob.svg ├── retryer.go ├── step_builder.go ├── step_builder_test.go ├── step_definition.go ├── step_exec_data.go ├── step_exec_options.go ├── step_instance.go └── test_joblib_test.go /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | groups: 13 | gomod: 14 | applies-to: version-updates 15 | dependency-type: production 16 | 17 | - package-ecosystem: github-actions 18 | directory: / 19 | schedule: 20 | interval: weekly 21 | groups: 22 | github-actions: 23 | applies-to: version-updates 24 | dependency-type: production 25 | 26 | - package-ecosystem: gomod 27 | directory: /graph 28 | schedule: 29 | interval: weekly 30 | groups: 31 | gomod: 32 | applies-to: version-updates 33 | dependency-type: production 34 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | # For most projects, this workflow file will not need changing; you simply need 2 | # to commit it to your repository. 3 | # 4 | # You may wish to alter this file to override the set of languages analyzed, 5 | # or to provide custom queries or build logic. 6 | name: "CodeQL" 7 | 8 | on: 9 | push: 10 | branches: [main] 11 | pull_request: 12 | # The branches below must be a subset of the branches above 13 | branches: [main] 14 | schedule: 15 | - cron: '0 14 * * 2' 16 | 17 | permissions: 18 | contents: read 19 | 20 | jobs: 21 | analyze: 22 | permissions: 23 | actions: read # for github/codeql-action/init to get workflow details 24 | contents: read # for actions/checkout to fetch code 25 | security-events: write # for github/codeql-action/autobuild to send a status report 26 | name: Analyze 27 | runs-on: ubuntu-latest 28 | 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | # Override automatic language detection by changing the below list 33 | # Supported options are ['csharp', 'cpp', 'go', 'java', 'javascript', 'python'] 34 | language: ['go'] 35 | # Learn more... 36 | # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection 37 | 38 | steps: 39 | - name: Harden Runner 40 | uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 41 | with: 42 | egress-policy: audit 43 | 44 | - name: Checkout repository 45 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 46 | with: 47 | # We must fetch at least the immediate parents so that if this is 48 | # a pull request then we can checkout the head. 49 | fetch-depth: 2 50 | 51 | # If this run was triggered by a pull request event, then checkout 52 | # the head of the pull request instead of the merge commit. 53 | - run: git checkout HEAD^2 54 | if: ${{ github.event_name == 'pull_request' }} 55 | 56 | # Initializes the CodeQL tools for scanning. 57 | - name: Initialize CodeQL 58 | uses: github/codeql-action/init@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 59 | with: 60 | languages: ${{ matrix.language }} 61 | # If you wish to specify custom queries, you can do so here or in a config file. 62 | # By default, queries listed here will override any specified in a config file. 63 | # Prefix the list here with "+" to use these queries and those in the config file. 64 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 65 | 66 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 67 | # If this step fails, then you should remove it and run the build manually (see below) 68 | - name: Autobuild 69 | uses: github/codeql-action/autobuild@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 70 | 71 | # ℹ️ Command-line programs to run using the OS shell. 72 | # 📚 https://git.io/JvXDl 73 | 74 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 75 | # and modify them (or add more) to build your code if your project 76 | # uses a compiled language 77 | 78 | #- run: | 79 | # make bootstrap 80 | # make release 81 | 82 | - name: Perform CodeQL Analysis 83 | uses: github/codeql-action/analyze@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 -------------------------------------------------------------------------------- /.github/workflows/dependency-review.yml: -------------------------------------------------------------------------------- 1 | # Dependency Review Action 2 | # 3 | # This Action will scan dependency manifest files that change as part of a Pull Request, 4 | # surfacing known-vulnerable versions of the packages declared or updated in the PR. 5 | # Once installed, if the workflow run is marked as required, 6 | # PRs introducing known-vulnerable packages will be blocked from merging. 7 | # 8 | # Source repository: https://github.com/actions/dependency-review-action 9 | name: 'Dependency Review' 10 | on: [pull_request] 11 | 12 | permissions: 13 | contents: read 14 | 15 | jobs: 16 | dependency-review: 17 | runs-on: ubuntu-latest 18 | steps: 19 | - name: Harden Runner 20 | uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 21 | with: 22 | egress-policy: audit 23 | 24 | - name: 'Checkout Repository' 25 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 26 | - name: 'Dependency Review' 27 | uses: actions/dependency-review-action@4081bf99e2866ebe428fc0477b69eb4fcda7220a # v4.4.0 28 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: Go 2 | 3 | on: 4 | push: 5 | branches: [ "main" ] 6 | pull_request: 7 | branches: [ "main" ] 8 | 9 | permissions: 10 | contents: read 11 | 12 | jobs: 13 | 14 | build: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Harden Runner 18 | uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 19 | with: 20 | egress-policy: audit 21 | 22 | - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 23 | 24 | - name: Set up Go 25 | uses: actions/setup-go@41dfa10bad2bb2ae585af6ee5bb4d7d973ad74ed # v5.1.0 26 | with: 27 | go-version: 1.21 28 | 29 | - name: Build 30 | run: go build -v ./... 31 | 32 | - name: Test 33 | run: go test -race -coverprofile=coverage.txt -covermode=atomic ./... 34 | 35 | - name: Codecov 36 | uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4.6.0 37 | -------------------------------------------------------------------------------- /.github/workflows/scorecards.yml: -------------------------------------------------------------------------------- 1 | # This workflow uses actions that are not certified by GitHub. They are provided 2 | # by a third-party and are governed by separate terms of service, privacy 3 | # policy, and support documentation. 4 | 5 | name: Scorecard supply-chain security 6 | on: 7 | # For Branch-Protection check. Only the default branch is supported. See 8 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#branch-protection 9 | branch_protection_rule: 10 | # To guarantee Maintained check is occasionally updated. See 11 | # https://github.com/ossf/scorecard/blob/main/docs/checks.md#maintained 12 | schedule: 13 | - cron: '20 7 * * 2' 14 | push: 15 | branches: ["main"] 16 | 17 | # Declare default permissions as read only. 18 | permissions: read-all 19 | 20 | jobs: 21 | analysis: 22 | name: Scorecard analysis 23 | runs-on: ubuntu-latest 24 | permissions: 25 | # Needed to upload the results to code-scanning dashboard. 26 | security-events: write 27 | # Needed to publish results and get a badge (see publish_results below). 28 | id-token: write 29 | contents: read 30 | actions: read 31 | 32 | steps: 33 | - name: Harden Runner 34 | uses: step-security/harden-runner@91182cccc01eb5e619899d80e4e971d6181294a7 # v2.10.1 35 | with: 36 | egress-policy: audit 37 | 38 | - name: "Checkout code" 39 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 40 | with: 41 | persist-credentials: false 42 | 43 | - name: "Run analysis" 44 | uses: ossf/scorecard-action@62b2cac7ed8198b15735ed49ab1e5cf35480ba46 # v2.4.0 45 | with: 46 | results_file: results.sarif 47 | results_format: sarif 48 | # (Optional) "write" PAT token. Uncomment the `repo_token` line below if: 49 | # - you want to enable the Branch-Protection check on a *public* repository, or 50 | # - you are installing Scorecards on a *private* repository 51 | # To create the PAT, follow the steps in https://github.com/ossf/scorecard-action#authentication-with-pat. 52 | # repo_token: ${{ secrets.SCORECARD_TOKEN }} 53 | 54 | # Public repositories: 55 | # - Publish results to OpenSSF REST API for easy access by consumers 56 | # - Allows the repository to include the Scorecard badge. 57 | # - See https://github.com/ossf/scorecard-action#publishing-results. 58 | # For private repositories: 59 | # - `publish_results` will always be set to `false`, regardless 60 | # of the value entered here. 61 | publish_results: true 62 | 63 | # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF 64 | # format to the repository Actions tab. 65 | - name: "Upload artifact" 66 | uses: actions/upload-artifact@b4b15b8c7c6ac21ea08fcf65892d2ee8f75cf882 # v4.4.3 67 | with: 68 | name: SARIF file 69 | path: results.sarif 70 | retention-days: 5 71 | 72 | # Upload the results to GitHub's code scanning dashboard. 73 | - name: "Upload to code-scanning" 74 | uses: github/codeql-action/upload-sarif@4f3212b61783c3c68e8309a0f18a699764811cda # v3.27.1 75 | with: 76 | sarif_file: results.sarif 77 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # github codespaces files 24 | oryxBuildBinary -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/gitleaks/gitleaks 3 | rev: v8.16.3 4 | hooks: 5 | - id: gitleaks 6 | - repo: https://github.com/golangci/golangci-lint 7 | rev: v1.52.2 8 | hooks: 9 | - id: golangci-lint 10 | - repo: https://github.com/pre-commit/pre-commit-hooks 11 | rev: v4.4.0 12 | hooks: 13 | - id: end-of-file-fixer 14 | - id: trailing-whitespace 15 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 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 | # AsyncJob 2 | 3 | AsyncJob aiming to help you organize code in dependencyGraph(DAG), instead of a sequential chain. 4 | 5 | # Concepts 6 | **JobDefinition** is a graph describe code blocks and their connections. 7 | - you can use AddStep, StepAfter, StepAfterBoth to organize steps in a JobDefinition. 8 | - jobDefinition can be and should be build and seal in package init time. 9 | - jobDefinition have a generic typed input 10 | - calling Start with the input, will instantiate an jobInstance, and steps will began to execute. 11 | - jobDefinition can be visualized using graphviz, easier for human to understand. 12 | 13 | **JobInstance** is an instance of JobDefinition, after calling .Start() method from JobDefinition 14 | - all Steps on the definition will be copied to JobInstance. 15 | - each step will be executed once it's precedent step is done. 16 | - jobInstance can be visualized as well, instance visualize contains detailed info(startTime, duration) on each step. 17 | 18 | **StepDefinition** is a individual code block which can be executed and have inputs, output. 19 | - StepDefinition describe it's preceding steps. 20 | - StepDefinition contains generic Params 21 | - ideally all stepMethod should come from JobInput (generic type on JobDefinition), or static method. To avoid shared state between jobs. 22 | - output of a step can be feed into next step as input, type is checked by go generics. 23 | 24 | **StepInstance** is instance of StepDefinition 25 | - step is wrapped in [AsyncTask](https://github.com/Azure/go-asynctask) 26 | - a step would be started once all it's dependency is finished. 27 | - executionPolicy can be applied {Retry, ContextEnrichment} 28 | 29 | # Usage 30 | 31 | ### Build and run a asyncjob 32 | ```golang 33 | 34 | // SqlSummaryAsyncJobDefinition is the job definition for the SqlSummaryJobLib 35 | // JobDefinition fit perfectly in init() function 36 | var SqlSummaryAsyncJobDefinition *asyncjob.JobDefinitionWithResult[SqlSummaryJobLib, SummarizedResult] 37 | 38 | func init() { 39 | var err error 40 | SqlSummaryAsyncJobDefinition, err = BuildJobWithResult(map[string]asyncjob.RetryPolicy{}) 41 | if err != nil { 42 | panic(err) 43 | } 44 | 45 | SqlSummaryAsyncJobDefinition.Seal() 46 | } 47 | 48 | func BuildJob(retryPolicies map[string]asyncjob.RetryPolicy) (*asyncjob.JobDefinition[SqlSummaryJobLib], error) { 49 | job := asyncjob.NewJobDefinition[SqlSummaryJobLib]("sqlSummaryJob") 50 | 51 | connTsk, err := asyncjob.AddStep(job, "GetConnection", connectionStepFunc, asyncjob.WithRetry(retryPolicies["GetConnection"]), asyncjob.WithContextEnrichment(EnrichContext)) 52 | if err != nil { 53 | return nil, fmt.Errorf("error adding step GetConnection: %w", err) 54 | } 55 | 56 | checkAuthTask, err := asyncjob.AddStep(job, "CheckAuth", checkAuthStepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 57 | if err != nil { 58 | return nil, fmt.Errorf("error adding step CheckAuth: %w", err) 59 | } 60 | 61 | table1ClientTsk, err := asyncjob.StepAfter(job, "GetTableClient1", connTsk, tableClient1StepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 62 | if err != nil { 63 | return nil, fmt.Errorf("error adding step GetTableClient1: %w", err) 64 | } 65 | 66 | qery1ResultTsk, err := asyncjob.StepAfter(job, "QueryTable1", table1ClientTsk, queryTable1StepFunc, asyncjob.WithRetry(retryPolicies["QueryTable1"]), asyncjob.ExecuteAfter(checkAuthTask), asyncjob.WithContextEnrichment(EnrichContext)) 67 | if err != nil { 68 | return nil, fmt.Errorf("error adding step QueryTable1: %w", err) 69 | } 70 | 71 | table2ClientTsk, err := asyncjob.StepAfter(job, "GetTableClient2", connTsk, tableClient2StepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 72 | if err != nil { 73 | return nil, fmt.Errorf("error adding step GetTableClient2: %w", err) 74 | } 75 | 76 | qery2ResultTsk, err := asyncjob.StepAfter(job, "QueryTable2", table2ClientTsk, queryTable2StepFunc, asyncjob.WithRetry(retryPolicies["QueryTable2"]), asyncjob.ExecuteAfter(checkAuthTask), asyncjob.WithContextEnrichment(EnrichContext)) 77 | if err != nil { 78 | return nil, fmt.Errorf("error adding step QueryTable2: %w", err) 79 | } 80 | 81 | summaryTsk, err := asyncjob.StepAfterBoth(job, "Summarize", qery1ResultTsk, qery2ResultTsk, summarizeQueryResultStepFunc, asyncjob.WithRetry(retryPolicies["Summarize"]), asyncjob.WithContextEnrichment(EnrichContext)) 82 | if err != nil { 83 | return nil, fmt.Errorf("error adding step Summarize: %w", err) 84 | } 85 | 86 | _, err = asyncjob.AddStep(job, "EmailNotification", emailNotificationStepFunc, asyncjob.ExecuteAfter(summaryTsk), asyncjob.WithContextEnrichment(EnrichContext)) 87 | if err != nil { 88 | return nil, fmt.Errorf("error adding step EmailNotification: %w", err) 89 | } 90 | return job, nil 91 | } 92 | // execute job 93 | jobInstance1 := SqlSummaryAsyncJobDefinition.Start(ctx, &SqlSummaryJobLib{...}) 94 | jobInstance2 := SqlSummaryAsyncJobDefinition.Start(ctx, &SqlSummaryJobLib{...}) 95 | 96 | // ... 97 | 98 | jobInstance1.Wait(context.WithTimeout(context.Background(), 10*time.Second)) 99 | jobInstance2.Wait(context.WithTimeout(context.Background(), 10*time.Second)) 100 | ``` 101 | 102 | ### visualize of a job 103 | ``` 104 | # visualize the job 105 | dotGraph := job.Visualize() 106 | fmt.Println(dotGraph) 107 | ``` 108 | 109 | ![visualize job graph](media/asyncjob.svg) 110 | 111 | ``` 112 | digraph { 113 | newrank = "true" 114 | "QueryTable2" [label="QueryTable2" shape=hexagon style=filled tooltip="State: completed\nStartAt: 2022-12-12T12:00:32.254054-08:00\nDuration: 13.207µs" fillcolor=green] 115 | "QueryTable1" [label="QueryTable1" shape=hexagon style=filled tooltip="State: completed\nStartAt: 2022-12-12T12:00:32.254098-08:00\nDuration: 11.394µs" fillcolor=green] 116 | "EmailNotification" [label="EmailNotification" shape=hexagon style=filled tooltip="State: completed\nStartAt: 2022-12-12T12:00:32.254143-08:00\nDuration: 11.757µs" fillcolor=green] 117 | "sqlSummaryJob" [label="sqlSummaryJob" shape=triangle style=filled tooltip="State: completed\nStartAt: 0001-01-01T00:00:00Z\nDuration: 0s" fillcolor=green] 118 | "GetConnection" [label="GetConnection" shape=hexagon style=filled tooltip="State: completed\nStartAt: 2022-12-12T12:00:32.253844-08:00\nDuration: 154.825µs" fillcolor=green] 119 | "GetTableClient2" [label="GetTableClient2" shape=hexagon style=filled tooltip="State: completed\nStartAt: 2022-12-12T12:00:32.254017-08:00\nDuration: 25.793µs" fillcolor=green] 120 | "GetTableClient1" [label="GetTableClient1" shape=hexagon style=filled tooltip="State: completed\nStartAt: 2022-12-12T12:00:32.254076-08:00\nDuration: 12.459µs" fillcolor=green] 121 | "Summarize" [label="Summarize" shape=hexagon style=filled tooltip="State: completed\nStartAt: 2022-12-12T12:00:32.254121-08:00\nDuration: 7.88µs" fillcolor=green] 122 | "CheckAuth" [label="CheckAuth" shape=hexagon style=filled tooltip="State: completed\nStartAt: 2022-12-12T12:00:32.253818-08:00\nDuration: 18.52µs" fillcolor=green] 123 | 124 | "CheckAuth" -> "QueryTable2" [style=bold tooltip="Time: 2022-12-12T12:00:32.254054-08:00" color=green] 125 | "CheckAuth" -> "QueryTable1" [style=bold tooltip="Time: 2022-12-12T12:00:32.254098-08:00" color=green] 126 | "GetTableClient2" -> "QueryTable2" [style=bold tooltip="Time: 2022-12-12T12:00:32.254054-08:00" color=green] 127 | "GetTableClient1" -> "QueryTable1" [style=bold tooltip="Time: 2022-12-12T12:00:32.254098-08:00" color=green] 128 | "QueryTable1" -> "Summarize" [style=bold tooltip="Time: 2022-12-12T12:00:32.254121-08:00" color=green] 129 | "QueryTable2" -> "Summarize" [style=bold tooltip="Time: 2022-12-12T12:00:32.254121-08:00" color=green] 130 | "Summarize" -> "EmailNotification" [style=bold tooltip="Time: 2022-12-12T12:00:32.254143-08:00" color=green] 131 | "sqlSummaryJob" -> "CheckAuth" [style=bold tooltip="Time: 2022-12-12T12:00:32.253818-08:00" color=green] 132 | "sqlSummaryJob" -> "GetConnection" [style=bold tooltip="Time: 2022-12-12T12:00:32.253844-08:00" color=green] 133 | "GetConnection" -> "GetTableClient2" [style=bold tooltip="Time: 2022-12-12T12:00:32.254017-08:00" color=green] 134 | "GetConnection" -> "GetTableClient1" [style=bold tooltip="Time: 2022-12-12T12:00:32.254076-08:00" color=green] 135 | } 136 | ``` 137 | 138 | ### collect result from job 139 | you can enrich job to aware result from given step, then you can collect result (strongly typed) from that step 140 | 141 | ``` 142 | var SqlSummaryAsyncJobDefinition *asyncjob.JobDefinitionWithResult[SqlSummaryJobLib, SummarizedResult] 143 | SqlSummaryAsyncJobDefinition = asyncjob.JobWithResult(job /*from previous section*/, summaryTsk) 144 | 145 | jobInstance1 := SqlSummaryAsyncJobDefinition.Start(ctx, &SqlSummaryJobLib{...}) 146 | result, err := jobInstance1.Result(ctx) 147 | ``` 148 | 149 | ### Overhead? 150 | - go routine will be created for each step in your jobDefinition, when you call .Start() 151 | - each step also hold tiny memory as well for state tracking. 152 | - userFunction is instrumented with state tracking, panic handling. 153 | 154 | Here is some simple visualize on how it actual looks like: 155 | ```mermaid 156 | gantt 157 | title asyncjob.Start() 158 | dateFormat HH:mm 159 | 160 | section GetConnection 161 | WaitPrecedingTasks :des11, 00:00,0ms 162 | userFunction :des12, after des11, 20ms 163 | 164 | section GetTableClient1 165 | WaitPrecedingTasks :des21, 00:00,20ms 166 | userFunction :des22, after des21, 15ms 167 | 168 | section GetTableClient2 169 | WaitPrecedingTasks :des31, 00:00,20ms 170 | userFunction :des32, after des31, 21ms 171 | 172 | section QueryTable1 173 | WaitPrecedingTasks :des41, 00:00,35ms 174 | userFunction :des42, after des41, 24ms 175 | 176 | section QueryTable2 177 | WaitPrecedingTasks :des51, 00:00,41ms 178 | userFunction :des52, after des51, 30ms 179 | 180 | section QueryResultSummarize 181 | WaitPrecedingTasks :des61, 00:00, 71ms 182 | userFunction :des62, after des61, 10ms 183 | ``` -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /SUPPORT.md: -------------------------------------------------------------------------------- 1 | # TODO: The maintainer of this repo has not yet edited this file 2 | 3 | **REPO OWNER**: Do you want Customer Service & Support (CSS) support for this product/project? 4 | 5 | - **No CSS support:** Fill out this template with information about how to file issues and get help. 6 | - **Yes CSS support:** Fill out an intake form at [aka.ms/onboardsupport](https://aka.ms/onboardsupport). CSS will work with/help you to determine next steps. 7 | - **Not sure?** Fill out an intake as though the answer were "Yes". CSS will help you decide. 8 | 9 | *Then remove this first heading from this SUPPORT.MD file before publishing your repo.* 10 | 11 | # Support 12 | 13 | ## How to file issues and get help 14 | 15 | This project uses GitHub Issues to track bugs and feature requests. Please search the existing 16 | issues before filing new issues to avoid duplicates. For new issues, file your bug or 17 | feature request as a new Issue. 18 | 19 | For help and questions about using this project, please **REPO MAINTAINER: INSERT INSTRUCTIONS HERE 20 | FOR HOW TO ENGAGE REPO OWNERS OR COMMUNITY FOR HELP. COULD BE A STACK OVERFLOW TAG OR OTHER 21 | CHANNEL. WHERE WILL YOU HELP PEOPLE?**. 22 | 23 | ## Microsoft Support Policy 24 | 25 | Support for this **PROJECT or PRODUCT** is limited to the resources listed above. 26 | -------------------------------------------------------------------------------- /error.go: -------------------------------------------------------------------------------- 1 | package asyncjob 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | ) 7 | 8 | type JobErrorCode string 9 | 10 | const ( 11 | ErrPrecedentStepFailed JobErrorCode = "PrecedentStepFailed" 12 | ErrStepFailed JobErrorCode = "StepFailed" 13 | 14 | ErrRefStepNotInJob JobErrorCode = "RefStepNotInJob" 15 | MsgRefStepNotInJob string = "trying to reference to step %q, but it is not registered in job" 16 | 17 | ErrAddStepInSealedJob JobErrorCode = "AddStepInSealedJob" 18 | MsgAddStepInSealedJob string = "trying to add step %q to a sealed job definition" 19 | 20 | ErrAddExistingStep JobErrorCode = "AddExistingStep" 21 | MsgAddExistingStep string = "trying to add step %q to job definition, but it already exists" 22 | 23 | ErrDuplicateInputParentStep JobErrorCode = "DuplicateInputParentStep" 24 | MsgDuplicateInputParentStep string = "at least 2 input parentSteps are same" 25 | 26 | ErrRuntimeStepNotFound JobErrorCode = "RuntimeStepNotFound" 27 | MsgRuntimeStepNotFound string = "runtime step %q not found, must be a bug in asyncjob" 28 | ) 29 | 30 | func (code JobErrorCode) Error() string { 31 | return string(code) 32 | } 33 | 34 | func (code JobErrorCode) WithMessage(msg string) *MessageError { 35 | return &MessageError{Code: code, Message: msg} 36 | } 37 | 38 | type MessageError struct { 39 | Code JobErrorCode 40 | Message string 41 | } 42 | 43 | func (me *MessageError) Error() string { 44 | return me.Code.Error() + ": " + me.Message 45 | } 46 | 47 | func (me *MessageError) Unwrap() error { 48 | return me.Code 49 | } 50 | 51 | type JobError struct { 52 | Code JobErrorCode 53 | StepError error 54 | StepInstance StepInstanceMeta 55 | Message string 56 | } 57 | 58 | func newStepError(code JobErrorCode, step StepInstanceMeta, stepErr error) *JobError { 59 | return &JobError{Code: code, StepInstance: step, StepError: stepErr} 60 | } 61 | 62 | func (je *JobError) Error() string { 63 | if je.Code == ErrStepFailed && je.StepError != nil { 64 | return fmt.Sprintf("step %q failed: %s", je.StepInstance.GetName(), je.StepError.Error()) 65 | } 66 | return je.Code.Error() + ": " + je.Message 67 | } 68 | 69 | func (je *JobError) Unwrap() error { 70 | return je.StepError 71 | } 72 | 73 | // RootCause track precendent chain and return the first step raised this error. 74 | func (je *JobError) RootCause() error { 75 | // this step failed, return the error 76 | if je.Code == ErrStepFailed { 77 | return je 78 | } 79 | 80 | // precendent step failure, track to the root 81 | if je.Code == ErrPrecedentStepFailed { 82 | precedentStepErr := &JobError{} 83 | if !errors.As(je.StepError, &precedentStepErr) { 84 | return je.StepError 85 | } 86 | return precedentStepErr.RootCause() 87 | } 88 | 89 | // no idea 90 | return je 91 | } 92 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Azure/go-asyncjob 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/Azure/go-asyncjob/graph v0.2.0 7 | github.com/Azure/go-asynctask v1.7.1 8 | github.com/google/uuid v1.6.0 9 | github.com/stretchr/testify v1.9.0 10 | ) 11 | 12 | require ( 13 | github.com/davecgh/go-spew v1.1.1 // indirect 14 | github.com/pmezard/go-difflib v1.0.0 // indirect 15 | gopkg.in/yaml.v3 v3.0.1 // indirect 16 | ) 17 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Azure/go-asyncjob/graph v0.2.0 h1:0GFnQit3+ZUxpc67ogusooa38GSFRPH2e1+h+L/33hc= 2 | github.com/Azure/go-asyncjob/graph v0.2.0/go.mod h1:3Z7w9aUBIrDriypH8O+hK0aeqKWKYuKSNxwrDxFy34s= 3 | github.com/Azure/go-asynctask v1.7.1 h1:JvXzaMfH4MPj7GOeyNdRvSN6ONqyc1ssqOswFtAUDkw= 4 | github.com/Azure/go-asynctask v1.7.1/go.mod h1:CHic3J3ZB+0mGAWFY+sPiDwy8fRc/PrXkw1jxSq4/Xs= 5 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 6 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 7 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 8 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 9 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 10 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 11 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 12 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 13 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 14 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 15 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 16 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 17 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 18 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= 19 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 20 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 21 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 22 | -------------------------------------------------------------------------------- /graph/error.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | type GraphCodeError string 4 | 5 | const ( 6 | ErrDuplicateNode GraphCodeError = "node with same key already exists in this graph" 7 | ErrConnectNotExistingNode GraphCodeError = "node to connect does not exist in this graph" 8 | ) 9 | 10 | func (ge GraphCodeError) Error() string { 11 | return string(ge) 12 | } 13 | 14 | type GraphError struct { 15 | Code GraphCodeError 16 | Message string 17 | } 18 | 19 | func NewGraphError(code GraphCodeError, message string) *GraphError { 20 | return &GraphError{ 21 | Code: code, 22 | Message: message, 23 | } 24 | } 25 | 26 | func (ge *GraphError) Error() string { 27 | return ge.Code.Error() + ": " + ge.Message 28 | } 29 | 30 | func (ge *GraphError) Unwrap() error { 31 | return ge.Code 32 | } 33 | -------------------------------------------------------------------------------- /graph/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/Azure/go-asyncjob/graph 2 | 3 | go 1.21 4 | 5 | require github.com/stretchr/testify v1.9.0 6 | 7 | require ( 8 | github.com/davecgh/go-spew v1.1.1 // indirect 9 | github.com/pmezard/go-difflib v1.0.0 // indirect 10 | gopkg.in/yaml.v3 v3.0.1 // indirect 11 | ) 12 | -------------------------------------------------------------------------------- /graph/go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 6 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 7 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 8 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 9 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 10 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 11 | -------------------------------------------------------------------------------- /graph/graph.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | ) 7 | 8 | // NodeConstrain is a constraint for a node in a graph 9 | type NodeConstrain interface { 10 | // Name of the node, should be unique in the graph 11 | GetName() string 12 | // DotSpec returns the dot spec for this node 13 | DotSpec() *DotNodeSpec 14 | } 15 | 16 | // EdgeSpecFunc is a function that returns the DOT specification for an edge. 17 | type EdgeSpecFunc[T NodeConstrain] func(from, to T) *DotEdgeSpec 18 | 19 | type Edge[NT NodeConstrain] struct { 20 | From NT 21 | To NT 22 | } 23 | 24 | // DotNodeSpec is the specification for a node in a DOT graph 25 | type DotNodeSpec struct { 26 | // id of the node 27 | Name string 28 | // display text of the node 29 | DisplayName string 30 | Tooltip string 31 | Shape string 32 | Style string 33 | FillColor string 34 | } 35 | 36 | // DotEdgeSpec is the specification for an edge in DOT graph 37 | type DotEdgeSpec struct { 38 | FromNodeName string 39 | ToNodeName string 40 | Tooltip string 41 | Style string 42 | Color string 43 | } 44 | 45 | // Graph hold the nodes and edges of a graph 46 | type Graph[NT NodeConstrain] struct { 47 | nodes map[string]NT 48 | nodeEdges map[string][]*Edge[NT] 49 | edgeSpecFunc EdgeSpecFunc[NT] 50 | } 51 | 52 | // NewGraph creates a new graph 53 | func NewGraph[NT NodeConstrain](edgeSpecFunc EdgeSpecFunc[NT]) *Graph[NT] { 54 | return &Graph[NT]{ 55 | nodes: make(map[string]NT), 56 | nodeEdges: make(map[string][]*Edge[NT]), 57 | edgeSpecFunc: edgeSpecFunc, 58 | } 59 | } 60 | 61 | // AddNode adds a node to the graph 62 | func (g *Graph[NT]) AddNode(n NT) error { 63 | nodeKey := n.GetName() 64 | if _, ok := g.nodes[nodeKey]; ok { 65 | return NewGraphError(ErrDuplicateNode, fmt.Sprintf("node with key %s already exists in this graph", nodeKey)) 66 | } 67 | g.nodes[nodeKey] = n 68 | 69 | return nil 70 | } 71 | 72 | func (g *Graph[NT]) Connect(from, to NT) error { 73 | fromNodeKey := from.GetName() 74 | toNodeKey := to.GetName() 75 | var ok bool 76 | if from, ok = g.nodes[fromNodeKey]; !ok { 77 | return NewGraphError(ErrConnectNotExistingNode, fmt.Sprintf("cannot connect node %s, it's not added in this graph yet", fromNodeKey)) 78 | } 79 | 80 | if to, ok = g.nodes[toNodeKey]; !ok { 81 | return NewGraphError(ErrConnectNotExistingNode, fmt.Sprintf("cannot connect node %s, it's not added in this graph yet", toNodeKey)) 82 | } 83 | 84 | g.nodeEdges[fromNodeKey] = append(g.nodeEdges[fromNodeKey], &Edge[NT]{From: from, To: to}) 85 | return nil 86 | } 87 | 88 | // https://en.wikipedia.org/wiki/DOT_(graph_description_language) 89 | func (g *Graph[NT]) ToDotGraph() (string, error) { 90 | nodes := make([]*DotNodeSpec, 0) 91 | for _, node := range g.nodes { 92 | nodes = append(nodes, node.DotSpec()) 93 | } 94 | 95 | edges := make([]*DotEdgeSpec, 0) 96 | for _, nodeEdges := range g.nodeEdges { 97 | for _, edge := range nodeEdges { 98 | edges = append(edges, g.edgeSpecFunc(edge.From, edge.To)) 99 | } 100 | } 101 | 102 | buf := new(bytes.Buffer) 103 | err := digraphTemplate.Execute(buf, templateRef{Nodes: nodes, Edges: edges}) 104 | if err != nil { 105 | return "", err 106 | } 107 | return buf.String(), nil 108 | } 109 | 110 | func (g *Graph[NT]) TopologicalSort() []NT { 111 | visited := make(map[string]bool) 112 | stack := make([]NT, 0) 113 | 114 | for _, node := range g.nodes { 115 | if !visited[node.GetName()] { 116 | g.topologicalSortInternal(node, &visited, &stack) 117 | } 118 | } 119 | return stack 120 | } 121 | 122 | func (g *Graph[NT]) topologicalSortInternal(node NT, visited *map[string]bool, stack *[]NT) { 123 | (*visited)[node.GetName()] = true 124 | for _, edge := range g.nodeEdges[node.GetName()] { 125 | if !(*visited)[edge.To.GetName()] { 126 | g.topologicalSortInternal(edge.To, visited, stack) 127 | } 128 | } 129 | *stack = append([]NT{node}, *stack...) 130 | } 131 | -------------------------------------------------------------------------------- /graph/graph_test.go: -------------------------------------------------------------------------------- 1 | package graph_test 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/Azure/go-asyncjob/graph" 9 | "github.com/stretchr/testify/assert" 10 | ) 11 | 12 | func TestSimpleGraph(t *testing.T) { 13 | g := graph.NewGraph(edgeSpecFromConnection) 14 | root := &testNode{Name: "root"} 15 | g.AddNode(root) 16 | calc1 := &testNode{Name: "calc1"} 17 | g.AddNode(calc1) 18 | calc2 := &testNode{Name: "calc2"} 19 | g.AddNode(calc2) 20 | summary := &testNode{Name: "summary"} 21 | g.AddNode(summary) 22 | 23 | g.Connect(root, calc1) 24 | g.Connect(root, calc2) 25 | g.Connect(calc1, summary) 26 | g.Connect(calc2, summary) 27 | 28 | graphStr, err := g.ToDotGraph() 29 | if err != nil { 30 | assert.NoError(t, err) 31 | } 32 | t.Log(graphStr) 33 | 34 | sortedNodes := g.TopologicalSort() 35 | assert.Equal(t, 4, len(sortedNodes)) 36 | assert.Equal(t, root, sortedNodes[0]) 37 | assert.Equal(t, summary, sortedNodes[3]) 38 | 39 | err = g.AddNode(calc1) 40 | assert.Error(t, err) 41 | assert.True(t, errors.Is(err, graph.ErrDuplicateNode)) 42 | 43 | calc3 := &testNode{Name: "calc3"} 44 | err = g.Connect(root, calc3) 45 | assert.Error(t, err) 46 | assert.True(t, errors.Is(err, graph.ErrConnectNotExistingNode)) 47 | } 48 | 49 | func TestDemoGraph(t *testing.T) { 50 | g := graph.NewGraph(edgeSpecFromConnection) 51 | root := &testNode{Name: "root"} 52 | g.AddNode(root) 53 | 54 | paramServerName := &testNode{Name: "param_serverName"} 55 | g.AddNode(paramServerName) 56 | g.Connect(root, paramServerName) 57 | connect := &testNode{Name: "func_getConnection"} 58 | g.AddNode(connect) 59 | g.Connect(paramServerName, connect) 60 | checkAuth := &testNode{Name: "func_checkAuth"} 61 | g.AddNode(checkAuth) 62 | 63 | paramTable1 := &testNode{Name: "param_table1"} 64 | g.AddNode(paramTable1) 65 | g.Connect(root, paramTable1) 66 | tableClient1 := &testNode{Name: "func_getTableClient1"} 67 | g.AddNode(tableClient1) 68 | g.Connect(connect, tableClient1) 69 | g.Connect(paramTable1, tableClient1) 70 | paramQuery1 := &testNode{Name: "param_query1"} 71 | g.AddNode(paramQuery1) 72 | g.Connect(root, paramQuery1) 73 | queryTable1 := &testNode{Name: "func_queryTable1"} 74 | g.AddNode(queryTable1) 75 | g.Connect(paramQuery1, queryTable1) 76 | g.Connect(tableClient1, queryTable1) 77 | g.Connect(checkAuth, queryTable1) 78 | 79 | paramTable2 := &testNode{Name: "param_table2"} 80 | g.AddNode(paramTable2) 81 | g.Connect(root, paramTable2) 82 | tableClient2 := &testNode{Name: "func_getTableClient2"} 83 | g.AddNode(tableClient2) 84 | g.Connect(connect, tableClient2) 85 | g.Connect(paramTable2, tableClient2) 86 | paramQuery2 := &testNode{Name: "param_query2"} 87 | g.AddNode(paramQuery2) 88 | g.Connect(root, paramQuery2) 89 | queryTable2 := &testNode{Name: "func_queryTable2"} 90 | g.AddNode(queryTable2) 91 | g.Connect(paramQuery2, queryTable2) 92 | g.Connect(tableClient2, queryTable2) 93 | g.Connect(checkAuth, queryTable2) 94 | 95 | summary := &testNode{Name: "func_summarize"} 96 | g.AddNode(summary) 97 | g.Connect(queryTable1, summary) 98 | g.Connect(queryTable2, summary) 99 | 100 | email := &testNode{Name: "func_email"} 101 | g.AddNode(email) 102 | g.Connect(summary, email) 103 | 104 | sortedNodes := g.TopologicalSort() 105 | for _, n := range sortedNodes { 106 | fmt.Println(n.GetName()) 107 | } 108 | } 109 | 110 | type testNode struct { 111 | Name string 112 | } 113 | 114 | func (tn *testNode) GetName() string { 115 | return tn.Name 116 | } 117 | 118 | func (tn *testNode) DotSpec() *graph.DotNodeSpec { 119 | return &graph.DotNodeSpec{ 120 | Name: tn.Name, 121 | DisplayName: tn.Name, 122 | Tooltip: tn.Name, 123 | Shape: "box", 124 | Style: "filled", 125 | FillColor: "green", 126 | } 127 | } 128 | 129 | func edgeSpecFromConnection(from, to *testNode) *graph.DotEdgeSpec { 130 | return &graph.DotEdgeSpec{ 131 | FromNodeName: from.GetName(), 132 | ToNodeName: to.GetName(), 133 | Tooltip: fmt.Sprintf("%s -> %s", from.DotSpec().Name, to.DotSpec().Name), 134 | Style: "solid", 135 | Color: "black", 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /graph/template.go: -------------------------------------------------------------------------------- 1 | package graph 2 | 3 | import ( 4 | "text/template" 5 | ) 6 | 7 | // https://www.graphviz.org/docs/ 8 | // http://magjac.com/graphviz-visual-editor/ 9 | 10 | var digraphTemplate = template.Must(template.New("digraph").Parse(digraphTemplateText)) 11 | 12 | type templateRef struct { 13 | Nodes []*DotNodeSpec 14 | Edges []*DotEdgeSpec 15 | } 16 | 17 | const digraphTemplateText = `digraph { 18 | newrank = "true" 19 | {{ range $node := $.Nodes}} "{{$node.Name}}" [label="{{$node.DisplayName}}" shape={{$node.Shape}} style={{$node.Style}} tooltip="{{$node.Tooltip}}" fillcolor={{$node.FillColor}}] 20 | {{ end }} 21 | {{ range $edge := $.Edges}} "{{$edge.FromNodeName}}" -> "{{$edge.ToNodeName}}" [style={{$edge.Style}} tooltip="{{$edge.Tooltip}}" color={{$edge.Color}}] 22 | {{ end }} 23 | }` 24 | -------------------------------------------------------------------------------- /job_definition.go: -------------------------------------------------------------------------------- 1 | package asyncjob 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | 8 | "github.com/Azure/go-asyncjob/graph" 9 | ) 10 | 11 | // Interface for a job definition 12 | type JobDefinitionMeta interface { 13 | GetName() string 14 | GetStep(stepName string) (StepDefinitionMeta, bool) // TODO: switch bool to error 15 | Seal() 16 | Sealed() bool 17 | Visualize() (string, error) 18 | 19 | // not exposing for now. 20 | addStep(step StepDefinitionMeta, precedingSteps ...StepDefinitionMeta) error 21 | getRootStep() StepDefinitionMeta 22 | } 23 | 24 | // JobDefinition defines a job with child steps, and step is organized in a Directed Acyclic Graph (DAG). 25 | type JobDefinition[T any] struct { 26 | name string 27 | 28 | sealed bool 29 | steps map[string]StepDefinitionMeta 30 | stepsDag *graph.Graph[StepDefinitionMeta] 31 | rootStep *StepDefinition[T] 32 | } 33 | 34 | // Create new JobDefinition 35 | // 36 | // it is suggest to build jobDefinition statically on process start, and reuse it for each job instance. 37 | func NewJobDefinition[T any](name string) *JobDefinition[T] { 38 | j := &JobDefinition[T]{ 39 | name: name, 40 | steps: make(map[string]StepDefinitionMeta), 41 | stepsDag: graph.NewGraph(connectStepDefinition), 42 | } 43 | 44 | rootStep := newStepDefinition[T](name, stepTypeRoot) 45 | j.rootStep = rootStep 46 | 47 | j.steps[j.rootStep.GetName()] = j.rootStep 48 | j.stepsDag.AddNode(j.rootStep) 49 | 50 | return j 51 | } 52 | 53 | // Start execution of the job definition. 54 | // 55 | // this will create and return new instance of the job 56 | // caller will then be able to wait for the job instance 57 | func (jd *JobDefinition[T]) Start(ctx context.Context, input T, jobOptions ...JobOptionPreparer) *JobInstance[T] { 58 | if !jd.Sealed() { 59 | jd.Seal() 60 | } 61 | 62 | ji := newJobInstance(jd, input, jobOptions...) 63 | ji.start(ctx) 64 | 65 | return ji 66 | } 67 | 68 | func (jd *JobDefinition[T]) getRootStep() StepDefinitionMeta { 69 | return jd.rootStep 70 | } 71 | 72 | func (jd *JobDefinition[T]) GetName() string { 73 | return jd.name 74 | } 75 | 76 | func (jd *JobDefinition[T]) Seal() { 77 | if jd.sealed { 78 | return 79 | } 80 | jd.sealed = true 81 | } 82 | 83 | func (jd *JobDefinition[T]) Sealed() bool { 84 | return jd.sealed 85 | } 86 | 87 | // GetStep returns the stepDefinition by name 88 | func (jd *JobDefinition[T]) GetStep(stepName string) (StepDefinitionMeta, bool) { 89 | stepMeta, ok := jd.steps[stepName] 90 | return stepMeta, ok 91 | } 92 | 93 | // AddStep adds a step to the job definition, with optional preceding steps 94 | func (jd *JobDefinition[T]) addStep(step StepDefinitionMeta, precedingSteps ...StepDefinitionMeta) error { 95 | jd.steps[step.GetName()] = step 96 | jd.stepsDag.AddNode(step) 97 | for _, precedingStep := range precedingSteps { 98 | if err := jd.stepsDag.Connect(precedingStep, step); err != nil { 99 | if errors.Is(err, graph.ErrConnectNotExistingNode) { 100 | return ErrRefStepNotInJob.WithMessage(fmt.Sprintf("referenced step %s not found", precedingStep.GetName())) 101 | } 102 | 103 | return err 104 | } 105 | } 106 | 107 | return nil 108 | } 109 | 110 | // Visualize the job definition in graphviz dot format 111 | func (jd *JobDefinition[T]) Visualize() (string, error) { 112 | return jd.stepsDag.ToDotGraph() 113 | } 114 | -------------------------------------------------------------------------------- /job_instance.go: -------------------------------------------------------------------------------- 1 | package asyncjob 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | 7 | "github.com/Azure/go-asyncjob/graph" 8 | "github.com/Azure/go-asynctask" 9 | "github.com/google/uuid" 10 | ) 11 | 12 | type JobInstanceMeta interface { 13 | GetJobInstanceId() string 14 | GetJobDefinition() JobDefinitionMeta 15 | GetStepInstance(stepName string) (StepInstanceMeta, bool) 16 | Wait(context.Context) error 17 | Visualize() (string, error) 18 | 19 | // not exposing for now 20 | addStepInstance(step StepInstanceMeta, precedingSteps ...StepInstanceMeta) 21 | } 22 | 23 | type JobExecutionOptions struct { 24 | Id string 25 | RunSequentially bool 26 | } 27 | 28 | type JobOptionPreparer func(*JobExecutionOptions) *JobExecutionOptions 29 | 30 | func WithJobId(jobId string) JobOptionPreparer { 31 | return func(options *JobExecutionOptions) *JobExecutionOptions { 32 | options.Id = jobId 33 | return options 34 | } 35 | } 36 | 37 | func WithSequentialExecution() JobOptionPreparer { 38 | return func(options *JobExecutionOptions) *JobExecutionOptions { 39 | options.RunSequentially = true 40 | return options 41 | } 42 | } 43 | 44 | // JobInstance is the instance of a jobDefinition 45 | type JobInstance[T any] struct { 46 | jobOptions *JobExecutionOptions 47 | input T 48 | Definition *JobDefinition[T] 49 | rootStep *StepInstance[T] 50 | steps map[string]StepInstanceMeta 51 | stepsDag *graph.Graph[StepInstanceMeta] 52 | } 53 | 54 | func newJobInstance[T any](jd *JobDefinition[T], input T, jobInstanceOptions ...JobOptionPreparer) *JobInstance[T] { 55 | ji := &JobInstance[T]{ 56 | Definition: jd, 57 | input: input, 58 | steps: map[string]StepInstanceMeta{}, 59 | stepsDag: graph.NewGraph(connectStepInstance), 60 | jobOptions: &JobExecutionOptions{}, 61 | } 62 | 63 | for _, decorator := range jobInstanceOptions { 64 | ji.jobOptions = decorator(ji.jobOptions) 65 | } 66 | 67 | if ji.jobOptions.Id == "" { 68 | ji.jobOptions.Id = uuid.New().String() 69 | } 70 | 71 | return ji 72 | } 73 | 74 | func (ji *JobInstance[T]) start(ctx context.Context) { 75 | // create root step instance 76 | ji.rootStep = newStepInstance(ji.Definition.rootStep, ji) 77 | ji.rootStep.task = asynctask.NewCompletedTask(ji.input) 78 | ji.rootStep.state = StepStateCompleted 79 | ji.steps[ji.rootStep.GetName()] = ji.rootStep 80 | ji.stepsDag.AddNode(ji.rootStep) 81 | 82 | // construct job instance graph, with TopologySort ordering 83 | orderedSteps := ji.Definition.stepsDag.TopologicalSort() 84 | for _, stepDef := range orderedSteps { 85 | if stepDef.GetName() == ji.Definition.GetName() { 86 | continue 87 | } 88 | ji.steps[stepDef.GetName()] = stepDef.createStepInstance(ctx, ji) 89 | 90 | if ji.jobOptions.RunSequentially { 91 | ji.steps[stepDef.GetName()].Waitable().Wait(ctx) 92 | } 93 | } 94 | } 95 | 96 | func (ji *JobInstance[T]) GetJobInstanceId() string { 97 | return ji.jobOptions.Id 98 | } 99 | 100 | func (ji *JobInstance[T]) GetJobDefinition() JobDefinitionMeta { 101 | return ji.Definition 102 | } 103 | 104 | // GetStepInstance returns the stepInstance by name 105 | func (ji *JobInstance[T]) GetStepInstance(stepName string) (StepInstanceMeta, bool) { 106 | stepMeta, ok := ji.steps[stepName] 107 | return stepMeta, ok 108 | } 109 | 110 | func (ji *JobInstance[T]) addStepInstance(step StepInstanceMeta, precedingSteps ...StepInstanceMeta) { 111 | ji.steps[step.GetName()] = step 112 | 113 | ji.stepsDag.AddNode(step) 114 | for _, precedingStep := range precedingSteps { 115 | ji.stepsDag.Connect(precedingStep, step) 116 | } 117 | } 118 | 119 | // Wait for all steps in the job to finish. 120 | func (ji *JobInstance[T]) Wait(ctx context.Context) error { 121 | var tasks []asynctask.Waitable 122 | for _, step := range ji.steps { 123 | tasks = append(tasks, step.Waitable()) 124 | } 125 | 126 | err := asynctask.WaitAll(ctx, &asynctask.WaitAllOptions{}, tasks...) 127 | 128 | // return rootCaused error if possible 129 | if err != nil { 130 | jobErr := &JobError{} 131 | if errors.As(err, &jobErr) { 132 | return jobErr.RootCause() 133 | } 134 | 135 | return err 136 | } 137 | 138 | return nil 139 | } 140 | 141 | // Visualize the job instance in graphviz dot format 142 | func (jd *JobInstance[T]) Visualize() (string, error) { 143 | return jd.stepsDag.ToDotGraph() 144 | } 145 | -------------------------------------------------------------------------------- /job_result.go: -------------------------------------------------------------------------------- 1 | package asyncjob 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | type JobDefinitionWithResult[Tin, Tout any] struct { 8 | *JobDefinition[Tin] 9 | resultStep *StepDefinition[Tout] 10 | } 11 | 12 | func JobWithResult[Tin, Tout any](jd *JobDefinition[Tin], resultStep *StepDefinition[Tout]) (*JobDefinitionWithResult[Tin, Tout], error) { 13 | sdGet, ok := jd.GetStep(resultStep.GetName()) 14 | if !ok || sdGet != resultStep { 15 | return nil, ErrRefStepNotInJob 16 | } 17 | 18 | return &JobDefinitionWithResult[Tin, Tout]{ 19 | JobDefinition: jd, 20 | resultStep: resultStep, 21 | }, nil 22 | } 23 | 24 | type JobInstanceWithResult[Tin, Tout any] struct { 25 | *JobInstance[Tin] 26 | resultStep *StepInstance[Tout] 27 | } 28 | 29 | func (jd *JobDefinitionWithResult[Tin, Tout]) Start(ctx context.Context, input Tin, jobOptions ...JobOptionPreparer) *JobInstanceWithResult[Tin, Tout] { 30 | ji := jd.JobDefinition.Start(ctx, input, jobOptions...) 31 | 32 | return &JobInstanceWithResult[Tin, Tout]{ 33 | JobInstance: ji, 34 | resultStep: getStrongTypedStepInstance(jd.resultStep, ji), 35 | } 36 | } 37 | 38 | // Result returns the result of the job from result step. 39 | // 40 | // it doesn't wait for all steps to finish, you can use Result() after Wait() if desired. 41 | func (ji *JobInstanceWithResult[Tin, Tout]) Result(ctx context.Context) (Tout, error) { 42 | return ji.resultStep.task.Result(ctx) 43 | } 44 | -------------------------------------------------------------------------------- /job_test.go: -------------------------------------------------------------------------------- 1 | package asyncjob_test 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "testing" 8 | "time" 9 | 10 | "github.com/Azure/go-asyncjob" 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | func TestSimpleJob(t *testing.T) { 15 | t.Parallel() 16 | 17 | jobInstance1 := SqlSummaryAsyncJobDefinition.Start(context.WithValue(context.Background(), testLoggingContextKey, t), NewSqlJobLib(&SqlSummaryJobParameters{ 18 | ServerName: "server1", 19 | Table1: "table1", 20 | Query1: "query1", 21 | Table2: "table2", 22 | Query2: "query2", 23 | }), asyncjob.WithJobId("jobInstance1")) 24 | 25 | jobInstance2 := SqlSummaryAsyncJobDefinition.Start(context.WithValue(context.Background(), testLoggingContextKey, t), NewSqlJobLib(&SqlSummaryJobParameters{ 26 | ServerName: "server2", 27 | Table1: "table3", 28 | Query1: "query3", 29 | Table2: "table4", 30 | Query2: "query4", 31 | }), asyncjob.WithJobId("jobInstance2")) 32 | 33 | jobInstance3 := SqlSummaryAsyncJobDefinition.Start(context.WithValue(context.Background(), testLoggingContextKey, t), NewSqlJobLib(&SqlSummaryJobParameters{ 34 | ServerName: "server3", 35 | Table1: "table5", 36 | Query1: "query5", 37 | Table2: "table6", 38 | Query2: "query6", 39 | }), asyncjob.WithSequentialExecution()) 40 | 41 | jobErr := jobInstance1.Wait(context.Background()) 42 | assert.NoError(t, jobErr) 43 | renderGraph(t, jobInstance1) 44 | 45 | jobErr = jobInstance2.Wait(context.Background()) 46 | assert.NoError(t, jobErr) 47 | renderGraph(t, jobInstance2) 48 | 49 | jobErr = jobInstance3.Wait(context.Background()) 50 | assert.NoError(t, jobErr) 51 | renderGraph(t, jobInstance3) 52 | 53 | jobResult, jobErr := jobInstance1.Result(context.Background()) 54 | assert.NoError(t, jobErr) 55 | assert.Equal(t, jobResult.QueryResult1["serverName"], "server1") 56 | assert.Equal(t, jobResult.QueryResult1["tableName"], "table1") 57 | assert.Equal(t, jobResult.QueryResult1["queryName"], "query1") 58 | assert.Equal(t, jobResult.QueryResult2["serverName"], "server1") 59 | assert.Equal(t, jobResult.QueryResult2["tableName"], "table2") 60 | assert.Equal(t, jobResult.QueryResult2["queryName"], "query2") 61 | 62 | jobResult3, jobErr := jobInstance3.Result(context.Background()) 63 | assert.NoError(t, jobErr) 64 | assert.Equal(t, jobResult3.QueryResult1["serverName"], "server3") 65 | assert.Equal(t, jobResult3.QueryResult1["tableName"], "table5") 66 | assert.Equal(t, jobResult3.QueryResult1["queryName"], "query5") 67 | assert.Equal(t, jobResult3.QueryResult2["serverName"], "server3") 68 | assert.Equal(t, jobResult3.QueryResult2["tableName"], "table6") 69 | assert.Equal(t, jobResult3.QueryResult2["queryName"], "query6") 70 | } 71 | 72 | func TestJobError(t *testing.T) { 73 | t.Parallel() 74 | 75 | ctx := context.WithValue(context.Background(), testLoggingContextKey, t) 76 | jobInstance := SqlSummaryAsyncJobDefinition.Start(ctx, NewSqlJobLib(&SqlSummaryJobParameters{ 77 | ServerName: "server1", 78 | Table1: "table1", 79 | Query1: "query1", 80 | Table2: "table2", 81 | Query2: "query2", 82 | ErrorInjection: map[string]func() error{ 83 | "GetTableClient.server1.table1": func() error { return fmt.Errorf("table1 not exists") }, 84 | }, 85 | })) 86 | 87 | err := jobInstance.Wait(context.Background()) 88 | assert.Error(t, err) 89 | 90 | jobErr := &asyncjob.JobError{} 91 | errors.As(err, &jobErr) 92 | assert.Equal(t, jobErr.Code, asyncjob.ErrStepFailed) 93 | assert.Equal(t, "GetTableClient1", jobErr.StepInstance.GetName()) 94 | } 95 | 96 | func TestJobPanic(t *testing.T) { 97 | t.Parallel() 98 | 99 | ctx := context.WithValue(context.Background(), testLoggingContextKey, t) 100 | jobInstance := SqlSummaryAsyncJobDefinition.Start(ctx, NewSqlJobLib(&SqlSummaryJobParameters{ 101 | ServerName: "server1", 102 | Table1: "table1", 103 | Query1: "query1", 104 | Table2: "table2", 105 | Query2: "query2", 106 | PanicInjection: map[string]bool{ 107 | "GetTableClient.server1.table2": true, 108 | }, 109 | })) 110 | 111 | err := jobInstance.Wait(context.Background()) 112 | assert.Error(t, err) 113 | 114 | jobErr := &asyncjob.JobError{} 115 | assert.True(t, errors.As(err, &jobErr)) 116 | assert.Equal(t, jobErr.Code, asyncjob.ErrStepFailed) 117 | assert.Equal(t, jobErr.StepInstance.GetName(), "GetTableClient2") 118 | } 119 | 120 | func TestJobStepRetryStepAfter(t *testing.T) { 121 | t.Parallel() 122 | jd, err := BuildJob(map[string]asyncjob.RetryPolicy{ 123 | "GetConnection": newLinearRetryPolicy(time.Millisecond*3, 3), 124 | "QueryTable1": newLinearRetryPolicy(time.Millisecond*3, 3), 125 | "Summarize": newLinearRetryPolicy(time.Millisecond*3, 3), 126 | }) 127 | assert.NoError(t, err) 128 | 129 | invalidStep := &asyncjob.StepDefinition[string]{} 130 | _, err = asyncjob.JobWithResult(jd, invalidStep) 131 | assert.Error(t, err) 132 | 133 | // newly created job definition should not be sealed 134 | assert.False(t, jd.Sealed()) 135 | 136 | ctx := context.WithValue(context.Background(), testLoggingContextKey, t) 137 | 138 | // gain code coverage on retry policy in StepAfter 139 | jobInstance := jd.Start(ctx, NewSqlJobLib(&SqlSummaryJobParameters{ 140 | ServerName: "server1", 141 | Table1: "table1", 142 | Query1: "query1", 143 | Table2: "table2", 144 | Query2: "query2", 145 | ErrorInjection: map[string]func() error{ 146 | "ExecuteQuery.server1.table1.query1": func() error { return fmt.Errorf("query exeeded memory limit") }, 147 | }, 148 | })) 149 | 150 | // once Start() is triggered, job definition should be sealed 151 | assert.True(t, jd.Sealed()) 152 | 153 | err = jobInstance.Wait(context.Background()) 154 | assert.Error(t, err) 155 | jobErr := &asyncjob.JobError{} 156 | errors.As(err, &jobErr) 157 | assert.Equal(t, jobErr.Code, asyncjob.ErrStepFailed) 158 | assert.Equal(t, "QueryTable1", jobErr.StepInstance.GetName()) 159 | exeData := jobErr.StepInstance.ExecutionData() 160 | assert.Equal(t, exeData.Retried.Count, uint(3)) 161 | 162 | // recoverable error 163 | errorInjectCount := 0 164 | jobInstance2 := jd.Start(ctx, NewSqlJobLib(&SqlSummaryJobParameters{ 165 | ServerName: "server1", 166 | Table1: "table1", 167 | Query1: "query1", 168 | Table2: "table2", 169 | Query2: "query2", 170 | ErrorInjection: map[string]func() error{ 171 | "ExecuteQuery.server1.table1.query1": func() error { 172 | errorInjectCount++ 173 | if errorInjectCount == 3 { // no error on 3rd retry 174 | return nil 175 | } 176 | return fmt.Errorf("query exeeded memory limit") 177 | }, 178 | }, 179 | })) 180 | err = jobInstance2.Wait(context.Background()) 181 | assert.NoError(t, err) 182 | } 183 | 184 | func TestJobStepRetryAddStep(t *testing.T) { 185 | t.Parallel() 186 | jd, err := BuildJob(map[string]asyncjob.RetryPolicy{ 187 | "GetConnection": newLinearRetryPolicy(time.Millisecond*3, 3), 188 | "QueryTable1": newLinearRetryPolicy(time.Millisecond*3, 3), 189 | "Summarize": newLinearRetryPolicy(time.Millisecond*3, 3), 190 | }) 191 | assert.NoError(t, err) 192 | 193 | invalidStep := &asyncjob.StepDefinition[string]{} 194 | _, err = asyncjob.JobWithResult(jd, invalidStep) 195 | assert.Error(t, err) 196 | 197 | // newly created job definition should not be sealed 198 | assert.False(t, jd.Sealed()) 199 | 200 | ctx := context.WithValue(context.Background(), testLoggingContextKey, t) 201 | 202 | // gain code coverage on retry policy in AddStep 203 | jobInstance := jd.Start(ctx, NewSqlJobLib(&SqlSummaryJobParameters{ 204 | ServerName: "server1", 205 | Table1: "table1", 206 | Query1: "query1", 207 | Table2: "table2", 208 | Query2: "query2", 209 | ErrorInjection: map[string]func() error{ 210 | "GetConnection": func() error { return fmt.Errorf("dial 1.2.3.4 timedout") }, 211 | }, 212 | })) 213 | err = jobInstance.Wait(context.Background()) 214 | assert.Error(t, err) 215 | jobErr := &asyncjob.JobError{} 216 | errors.As(err, &jobErr) 217 | assert.Equal(t, jobErr.Code, asyncjob.ErrStepFailed) 218 | assert.Equal(t, "GetConnection", jobErr.StepInstance.GetName()) 219 | exeData := jobErr.StepInstance.ExecutionData() 220 | assert.Equal(t, exeData.Retried.Count, uint(3)) 221 | 222 | // recoverable error 223 | errorInjectCount := 0 224 | jobInstance2 := jd.Start(ctx, NewSqlJobLib(&SqlSummaryJobParameters{ 225 | ServerName: "server1", 226 | Table1: "table1", 227 | Query1: "query1", 228 | Table2: "table2", 229 | Query2: "query2", 230 | ErrorInjection: map[string]func() error{ 231 | "GetConnection": func() error { 232 | errorInjectCount++ 233 | if errorInjectCount == 3 { // no error on 3rd retry 234 | return nil 235 | } 236 | return fmt.Errorf("dial 1.2.3.4 timedout") 237 | }, 238 | }, 239 | })) 240 | err = jobInstance2.Wait(context.Background()) 241 | assert.NoError(t, err) 242 | } 243 | 244 | func TestJobStepRetryAfterBoth(t *testing.T) { 245 | t.Parallel() 246 | jd, err := BuildJob(map[string]asyncjob.RetryPolicy{ 247 | "GetConnection": newLinearRetryPolicy(time.Millisecond*3, 3), 248 | "QueryTable1": newLinearRetryPolicy(time.Millisecond*3, 3), 249 | "Summarize": newLinearRetryPolicy(time.Millisecond*3, 3), 250 | }) 251 | assert.NoError(t, err) 252 | 253 | invalidStep := &asyncjob.StepDefinition[string]{} 254 | _, err = asyncjob.JobWithResult(jd, invalidStep) 255 | assert.Error(t, err) 256 | 257 | // newly created job definition should not be sealed 258 | assert.False(t, jd.Sealed()) 259 | 260 | ctx := context.WithValue(context.Background(), testLoggingContextKey, t) 261 | 262 | // gain code coverage on retry policy in AfterBoth 263 | jobInstance := jd.Start(ctx, NewSqlJobLib(&SqlSummaryJobParameters{ 264 | ServerName: "server1", 265 | Table1: "table1", 266 | Query1: "query1", 267 | Table2: "table2", 268 | Query2: "query2", 269 | ErrorInjection: map[string]func() error{ 270 | "SummarizeQueryResult": func() error { return fmt.Errorf("result1 and result2 having different schema version, cannot merge.") }, 271 | }, 272 | })) 273 | err = jobInstance.Wait(context.Background()) 274 | assert.Error(t, err) 275 | jobErr := &asyncjob.JobError{} 276 | errors.As(err, &jobErr) 277 | assert.Equal(t, jobErr.Code, asyncjob.ErrStepFailed) 278 | assert.Equal(t, "Summarize", jobErr.StepInstance.GetName()) 279 | exeData := jobErr.StepInstance.ExecutionData() 280 | assert.Equal(t, exeData.Retried.Count, uint(3)) 281 | 282 | // recoverable error 283 | errorInjectCount := 0 284 | jobInstance2 := jd.Start(ctx, NewSqlJobLib(&SqlSummaryJobParameters{ 285 | ServerName: "server1", 286 | Table1: "table1", 287 | Query1: "query1", 288 | Table2: "table2", 289 | Query2: "query2", 290 | ErrorInjection: map[string]func() error{ 291 | "SummarizeQueryResult": func() error { 292 | errorInjectCount++ 293 | if errorInjectCount == 3 { // no error on 3rd retry 294 | return nil 295 | } 296 | return fmt.Errorf("result1 and result2 having different schema version, cannot merge.") 297 | }, 298 | }, 299 | })) 300 | err = jobInstance2.Wait(context.Background()) 301 | assert.NoError(t, err) 302 | } 303 | 304 | func renderGraph(t *testing.T, jb GraphRender) { 305 | graphStr, err := jb.Visualize() 306 | assert.NoError(t, err) 307 | 308 | t.Log(graphStr) 309 | } 310 | 311 | type GraphRender interface { 312 | Visualize() (string, error) 313 | } 314 | -------------------------------------------------------------------------------- /media/asyncjob.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | QueryTable2 7 | 8 | 9 | QueryTable2 10 | 11 | 12 | 13 | 14 | 15 | Summarize 16 | 17 | 18 | Summarize 19 | 20 | 21 | 22 | 23 | 24 | QueryTable2->Summarize 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | QueryTable1 34 | 35 | 36 | QueryTable1 37 | 38 | 39 | 40 | 41 | 42 | QueryTable1->Summarize 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | EmailNotification 52 | 53 | 54 | EmailNotification 55 | 56 | 57 | 58 | 59 | 60 | sqlSummaryJob 61 | 62 | 63 | sqlSummaryJob 64 | 65 | 66 | 67 | 68 | 69 | GetConnection 70 | 71 | 72 | GetConnection 73 | 74 | 75 | 76 | 77 | 78 | sqlSummaryJob->GetConnection 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | CheckAuth 88 | 89 | 90 | CheckAuth 91 | 92 | 93 | 94 | 95 | 96 | sqlSummaryJob->CheckAuth 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | GetTableClient2 106 | 107 | 108 | GetTableClient2 109 | 110 | 111 | 112 | 113 | 114 | GetConnection->GetTableClient2 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | GetTableClient1 124 | 125 | 126 | GetTableClient1 127 | 128 | 129 | 130 | 131 | 132 | GetConnection->GetTableClient1 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | GetTableClient2->QueryTable2 142 | 143 | 144 | 145 | 146 | 147 | 148 | 149 | 150 | GetTableClient1->QueryTable1 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | Summarize->EmailNotification 160 | 161 | 162 | 163 | 164 | 165 | 166 | 167 | 168 | CheckAuth->QueryTable2 169 | 170 | 171 | 172 | 173 | 174 | 175 | 176 | 177 | CheckAuth->QueryTable1 178 | 179 | 180 | 181 | 182 | 183 | 184 | 185 | -------------------------------------------------------------------------------- /retryer.go: -------------------------------------------------------------------------------- 1 | package asyncjob 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // internal retryer to execute RetryPolicy interface 8 | type retryer[T any] struct { 9 | retryPolicy RetryPolicy 10 | retryReport *RetryReport 11 | function func() (T, error) 12 | } 13 | 14 | func newRetryer[T any](policy RetryPolicy, report *RetryReport, toRetry func() (T, error)) *retryer[T] { 15 | return &retryer[T]{retryPolicy: policy, retryReport: report, function: toRetry} 16 | } 17 | 18 | func (r retryer[T]) Run() (T, error) { 19 | t, err := r.function() 20 | for err != nil { 21 | if shouldRetry, duration := r.retryPolicy.ShouldRetry(err, r.retryReport.Count); shouldRetry { 22 | r.retryReport.Count++ 23 | time.Sleep(duration) 24 | t, err = r.function() 25 | } else { 26 | break 27 | } 28 | } 29 | 30 | return t, err 31 | } 32 | -------------------------------------------------------------------------------- /step_builder.go: -------------------------------------------------------------------------------- 1 | package asyncjob 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "runtime/debug" 7 | "time" 8 | 9 | "github.com/Azure/go-asynctask" 10 | ) 11 | 12 | // AddStep adds a step to the job definition. 13 | func AddStep[JT, ST any](j *JobDefinition[JT], stepName string, stepFuncCreator func(input JT) asynctask.AsyncFunc[ST], optionDecorators ...ExecutionOptionPreparer) (*StepDefinition[ST], error) { 14 | if err := addStepPreCheck(j, stepName); err != nil { 15 | return nil, err 16 | } 17 | 18 | stepD := newStepDefinition[ST](stepName, stepTypeTask, optionDecorators...) 19 | precedingDefSteps, err := getDependsOnSteps(j, stepD.DependsOn()) 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | // if a step have no preceding tasks, link it to our rootJob as preceding task, so it won't start yet. 25 | if len(precedingDefSteps) == 0 { 26 | precedingDefSteps = append(precedingDefSteps, j.getRootStep()) 27 | stepD.executionOptions.DependOn = append(stepD.executionOptions.DependOn, j.getRootStep().GetName()) 28 | } 29 | 30 | stepD.instanceCreator = func(ctx context.Context, ji JobInstanceMeta) StepInstanceMeta { 31 | // TODO: error is ignored here 32 | precedingInstances, precedingTasks, _ := getDependsOnStepInstances(stepD, ji) 33 | 34 | jiStrongTyped := ji.(*JobInstance[JT]) 35 | stepFunc := stepFuncCreator(jiStrongTyped.input) 36 | stepFuncWithPanicHandling := func(ctx context.Context) (result ST, err error) { 37 | // handle panic from user code 38 | defer func() { 39 | if r := recover(); r != nil { 40 | err = fmt.Errorf("panic cought: %v, StackTrace: %s", r, debug.Stack()) 41 | } 42 | }() 43 | 44 | result, err = stepFunc(ctx) 45 | return result, err 46 | } 47 | 48 | stepInstance := newStepInstance(stepD, ji) 49 | stepInstance.task = asynctask.Start(ctx, instrumentedAddStep(stepInstance, precedingTasks, stepFuncWithPanicHandling)) 50 | ji.addStepInstance(stepInstance, precedingInstances...) 51 | return stepInstance 52 | } 53 | 54 | if err := j.addStep(stepD, precedingDefSteps...); err != nil { 55 | return nil, err 56 | } 57 | return stepD, nil 58 | } 59 | 60 | // StepAfter add a step after a preceding step, also take input from that preceding step 61 | func StepAfter[JT, PT, ST any](j *JobDefinition[JT], stepName string, parentStep *StepDefinition[PT], stepAfterFuncCreator func(input JT) asynctask.ContinueFunc[PT, ST], optionDecorators ...ExecutionOptionPreparer) (*StepDefinition[ST], error) { 62 | if err := addStepPreCheck(j, stepName); err != nil { 63 | return nil, err 64 | } 65 | 66 | stepD := newStepDefinition[ST](stepName, stepTypeTask, append(optionDecorators, ExecuteAfter(parentStep))...) 67 | precedingDefSteps, err := getDependsOnSteps(j, stepD.DependsOn()) 68 | if err != nil { 69 | return nil, err 70 | } 71 | 72 | stepD.instanceCreator = func(ctx context.Context, ji JobInstanceMeta) StepInstanceMeta { 73 | // TODO: error is ignored here 74 | precedingInstances, precedingTasks, _ := getDependsOnStepInstances(stepD, ji) 75 | 76 | jiStrongTyped := ji.(*JobInstance[JT]) 77 | stepFunc := stepAfterFuncCreator(jiStrongTyped.input) 78 | stepFuncWithPanicHandling := func(ctx context.Context, pt PT) (result ST, err error) { 79 | // handle panic from user code 80 | defer func() { 81 | if r := recover(); r != nil { 82 | err = fmt.Errorf("panic cought: %v, StackTrace: %s", r, debug.Stack()) 83 | } 84 | }() 85 | 86 | result, err = stepFunc(ctx, pt) 87 | return result, err 88 | } 89 | 90 | parentStepInstance := getStrongTypedStepInstance(parentStep, ji) 91 | stepInstance := newStepInstance(stepD, ji) 92 | // here ContinueWith may not invoke instrumentedStepAfterBoth at all, if parentStep1 or parentStep2 returns error. 93 | stepInstance.task = asynctask.ContinueWith(ctx, parentStepInstance.task, instrumentedStepAfter(stepInstance, precedingTasks, stepFuncWithPanicHandling)) 94 | ji.addStepInstance(stepInstance, precedingInstances...) 95 | return stepInstance 96 | } 97 | 98 | if err := j.addStep(stepD, precedingDefSteps...); err != nil { 99 | return nil, err 100 | } 101 | return stepD, nil 102 | } 103 | 104 | // StepAfterBoth add a step after both preceding steps, also take input from both preceding steps 105 | func StepAfterBoth[JT, PT1, PT2, ST any](j *JobDefinition[JT], stepName string, parentStep1 *StepDefinition[PT1], parentStep2 *StepDefinition[PT2], stepAfterBothFuncCreator func(input JT) asynctask.AfterBothFunc[PT1, PT2, ST], optionDecorators ...ExecutionOptionPreparer) (*StepDefinition[ST], error) { 106 | if err := addStepPreCheck(j, stepName); err != nil { 107 | return nil, err 108 | } 109 | 110 | // compiler not allow me to compare parentStep1 and parentStep2 directly with different genericType 111 | if parentStep1.GetName() == parentStep2.GetName() { 112 | return nil, ErrDuplicateInputParentStep.WithMessage(MsgDuplicateInputParentStep) 113 | } 114 | 115 | stepD := newStepDefinition[ST](stepName, stepTypeTask, append(optionDecorators, ExecuteAfter(parentStep1), ExecuteAfter(parentStep2))...) 116 | precedingDefSteps, err := getDependsOnSteps(j, stepD.DependsOn()) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | stepD.instanceCreator = func(ctx context.Context, ji JobInstanceMeta) StepInstanceMeta { 122 | // TODO: error is ignored here 123 | precedingInstances, precedingTasks, _ := getDependsOnStepInstances(stepD, ji) 124 | 125 | jiStrongTyped := ji.(*JobInstance[JT]) 126 | stepFunc := stepAfterBothFuncCreator(jiStrongTyped.input) 127 | stepFuncWithPanicHandling := func(ctx context.Context, pt1 PT1, pt2 PT2) (result ST, err error) { 128 | // handle panic from user code 129 | defer func() { 130 | if r := recover(); r != nil { 131 | err = fmt.Errorf("panic cought: %v, StackTrace: %s", r, debug.Stack()) 132 | } 133 | }() 134 | 135 | result, err = stepFunc(ctx, pt1, pt2) 136 | return result, err 137 | } 138 | parentStepInstance1 := getStrongTypedStepInstance(parentStep1, ji) 139 | parentStepInstance2 := getStrongTypedStepInstance(parentStep2, ji) 140 | stepInstance := newStepInstance(stepD, ji) 141 | // here AfterBoth may not invoke instrumentedStepAfterBoth at all, if parentStep1 or parentStep2 returns error. 142 | stepInstance.task = asynctask.AfterBoth(ctx, parentStepInstance1.task, parentStepInstance2.task, instrumentedStepAfterBoth(stepInstance, precedingTasks, stepFuncWithPanicHandling)) 143 | ji.addStepInstance(stepInstance, precedingInstances...) 144 | return stepInstance 145 | } 146 | 147 | if err := j.addStep(stepD, precedingDefSteps...); err != nil { 148 | return nil, err 149 | } 150 | return stepD, nil 151 | } 152 | 153 | // AddStepWithStaticFunc is same as AddStep, but the stepFunc passed in shouldn't have receiver. (or you get shared state between job instances) 154 | func AddStepWithStaticFunc[JT, ST any](j *JobDefinition[JT], stepName string, stepFunc asynctask.AsyncFunc[ST], optionDecorators ...ExecutionOptionPreparer) (*StepDefinition[ST], error) { 155 | return AddStep(j, stepName, func(j JT) asynctask.AsyncFunc[ST] { return stepFunc }, optionDecorators...) 156 | } 157 | 158 | // StepAfterWithStaticFunc is same as StepAfter, but the stepFunc passed in shouldn't have receiver. (or you get shared state between job instances) 159 | func StepAfterWithStaticFunc[JT, PT, ST any](j *JobDefinition[JT], stepName string, parentStep *StepDefinition[PT], stepFunc asynctask.ContinueFunc[PT, ST], optionDecorators ...ExecutionOptionPreparer) (*StepDefinition[ST], error) { 160 | return StepAfter(j, stepName, parentStep, func(j JT) asynctask.ContinueFunc[PT, ST] { return stepFunc }, optionDecorators...) 161 | } 162 | 163 | // StepAfterBothWithStaticFunc is same as StepAfterBoth, but the stepFunc passed in shouldn't have receiver. (or you get shared state between job instances) 164 | func StepAfterBothWithStaticFunc[JT, PT1, PT2, ST any](j *JobDefinition[JT], stepName string, parentStep1 *StepDefinition[PT1], parentStep2 *StepDefinition[PT2], stepFunc asynctask.AfterBothFunc[PT1, PT2, ST], optionDecorators ...ExecutionOptionPreparer) (*StepDefinition[ST], error) { 165 | return StepAfterBoth(j, stepName, parentStep1, parentStep2, func(j JT) asynctask.AfterBothFunc[PT1, PT2, ST] { return stepFunc }, optionDecorators...) 166 | } 167 | 168 | func instrumentedAddStep[T any](stepInstance *StepInstance[T], precedingTasks []asynctask.Waitable, stepFunc func(ctx context.Context) (T, error)) func(ctx context.Context) (T, error) { 169 | return func(ctx context.Context) (T, error) { 170 | if err := asynctask.WaitAll(ctx, &asynctask.WaitAllOptions{}, precedingTasks...); err != nil { 171 | /* this only work on ExecuteAfter (have precedent step, but not taking input from it) 172 | asynctask.ContinueWith and asynctask.AfterBoth won't invoke instrumentedFunc if any of the preceding task failed. 173 | we need to be consistent on before we do any state change or error handling. */ 174 | return *new(T), err 175 | } 176 | 177 | stepInstance.executionData.StartTime = time.Now() 178 | stepInstance.state = StepStateRunning 179 | ctx = stepInstance.EnrichContext(ctx) 180 | 181 | var result T 182 | var err error 183 | if stepInstance.Definition.executionOptions.RetryPolicy != nil { 184 | stepInstance.executionData.Retried = &RetryReport{} 185 | result, err = newRetryer(stepInstance.Definition.executionOptions.RetryPolicy, stepInstance.executionData.Retried, func() (T, error) { return stepFunc(ctx) }).Run() 186 | } else { 187 | result, err = stepFunc(ctx) 188 | } 189 | 190 | stepInstance.executionData.Duration = time.Since(stepInstance.executionData.StartTime) 191 | 192 | if err != nil { 193 | stepInstance.state = StepStateFailed 194 | return *new(T), newStepError(ErrStepFailed, stepInstance, err) 195 | } else { 196 | stepInstance.state = StepStateCompleted 197 | return result, nil 198 | } 199 | } 200 | } 201 | 202 | func instrumentedStepAfter[T, S any](stepInstance *StepInstance[S], precedingTasks []asynctask.Waitable, stepFunc func(ctx context.Context, t T) (S, error)) func(ctx context.Context, t T) (S, error) { 203 | return func(ctx context.Context, t T) (S, error) { 204 | if err := asynctask.WaitAll(ctx, &asynctask.WaitAllOptions{}, precedingTasks...); err != nil { 205 | /* this only work on ExecuteAfter (have precedent step, but not taking input from it) 206 | asynctask.ContinueWith and asynctask.AfterBoth won't invoke instrumentedFunc if any of the preceding task failed. 207 | we need to be consistent on before we do any state change or error handling. */ 208 | return *new(S), err 209 | } 210 | 211 | stepInstance.executionData.StartTime = time.Now() 212 | stepInstance.state = StepStateRunning 213 | ctx = stepInstance.EnrichContext(ctx) 214 | 215 | var result S 216 | var err error 217 | if stepInstance.Definition.executionOptions.RetryPolicy != nil { 218 | stepInstance.executionData.Retried = &RetryReport{} 219 | result, err = newRetryer(stepInstance.Definition.executionOptions.RetryPolicy, stepInstance.executionData.Retried, func() (S, error) { return stepFunc(ctx, t) }).Run() 220 | } else { 221 | result, err = stepFunc(ctx, t) 222 | } 223 | 224 | stepInstance.executionData.Duration = time.Since(stepInstance.executionData.StartTime) 225 | 226 | if err != nil { 227 | stepInstance.state = StepStateFailed 228 | return *new(S), newStepError(ErrStepFailed, stepInstance, err) 229 | } else { 230 | stepInstance.state = StepStateCompleted 231 | return result, nil 232 | } 233 | } 234 | } 235 | 236 | func instrumentedStepAfterBoth[T, S, R any](stepInstance *StepInstance[R], precedingTasks []asynctask.Waitable, stepFunc func(ctx context.Context, t T, s S) (R, error)) func(ctx context.Context, t T, s S) (R, error) { 237 | return func(ctx context.Context, t T, s S) (R, error) { 238 | 239 | if err := asynctask.WaitAll(ctx, &asynctask.WaitAllOptions{}, precedingTasks...); err != nil { 240 | /* this only work on ExecuteAfter (have precedent step, but not taking input from it) 241 | asynctask.ContinueWith and asynctask.AfterBoth won't invoke instrumentedFunc if any of the preceding task failed. 242 | we need to be consistent on before we do any state change or error handling. */ 243 | return *new(R), err 244 | } 245 | 246 | stepInstance.executionData.StartTime = time.Now() 247 | stepInstance.state = StepStateRunning 248 | ctx = stepInstance.EnrichContext(ctx) 249 | 250 | var result R 251 | var err error 252 | if stepInstance.Definition.executionOptions.RetryPolicy != nil { 253 | stepInstance.executionData.Retried = &RetryReport{} 254 | result, err = newRetryer(stepInstance.Definition.executionOptions.RetryPolicy, stepInstance.executionData.Retried, func() (R, error) { return stepFunc(ctx, t, s) }).Run() 255 | } else { 256 | result, err = stepFunc(ctx, t, s) 257 | } 258 | 259 | stepInstance.executionData.Duration = time.Since(stepInstance.executionData.StartTime) 260 | 261 | if err != nil { 262 | stepInstance.state = StepStateFailed 263 | return *new(R), newStepError(ErrStepFailed, stepInstance, err) 264 | } else { 265 | stepInstance.state = StepStateCompleted 266 | return result, nil 267 | } 268 | } 269 | } 270 | 271 | func addStepPreCheck(j JobDefinitionMeta, stepName string) error { 272 | if j.Sealed() { 273 | return ErrAddStepInSealedJob.WithMessage(fmt.Sprintf(MsgAddStepInSealedJob, stepName)) 274 | } 275 | 276 | if _, ok := j.GetStep(stepName); ok { 277 | return ErrAddExistingStep.WithMessage(fmt.Sprintf(MsgAddExistingStep, stepName)) 278 | } 279 | 280 | return nil 281 | } 282 | 283 | func getDependsOnSteps(j JobDefinitionMeta, dependsOnSteps []string) ([]StepDefinitionMeta, error) { 284 | var precedingDefSteps []StepDefinitionMeta 285 | for _, depStepName := range dependsOnSteps { 286 | if depStep, ok := j.GetStep(depStepName); ok { 287 | precedingDefSteps = append(precedingDefSteps, depStep) 288 | } else { 289 | return nil, ErrRefStepNotInJob.WithMessage(fmt.Sprintf(MsgRefStepNotInJob, depStepName)) 290 | } 291 | } 292 | 293 | return precedingDefSteps, nil 294 | } 295 | 296 | func getDependsOnStepInstances(stepD StepDefinitionMeta, ji JobInstanceMeta) ([]StepInstanceMeta, []asynctask.Waitable, error) { 297 | var precedingInstances []StepInstanceMeta 298 | var precedingTasks []asynctask.Waitable 299 | for _, depStepName := range stepD.DependsOn() { 300 | if depStep, ok := ji.GetStepInstance(depStepName); ok { 301 | precedingInstances = append(precedingInstances, depStep) 302 | precedingTasks = append(precedingTasks, depStep.Waitable()) 303 | } else { 304 | return nil, nil, ErrRuntimeStepNotFound.WithMessage(fmt.Sprintf(MsgRuntimeStepNotFound, depStepName)) 305 | } 306 | } 307 | 308 | return precedingInstances, precedingTasks, nil 309 | } 310 | 311 | // this is most vulunerable point of this library 312 | // 313 | // we have strongTyped steps 314 | // we can create stronglyTyped stepInstance from stronglyTyped stepDefinition 315 | // We cannot store strongTyped stepInstance and passing it to next step 316 | // now we need this typeAssertion, to beable to link steps 317 | // in theory, we have all the info, we construct the instance, if it panics, we should fix it. 318 | func getStrongTypedStepInstance[T any](stepD *StepDefinition[T], ji JobInstanceMeta) *StepInstance[T] { 319 | stepInstanceMeta, ok := ji.GetStepInstance(stepD.GetName()) 320 | if !ok { 321 | panic(fmt.Sprintf("step [%s] not found in jobInstance", stepD.GetName())) 322 | } 323 | 324 | stepInstance, ok := stepInstanceMeta.(*StepInstance[T]) 325 | if !ok { 326 | panic(fmt.Sprintf("step [%s] in jobInstance is not expected Type", stepD.GetName())) 327 | } 328 | 329 | return stepInstance 330 | } 331 | -------------------------------------------------------------------------------- /step_builder_test.go: -------------------------------------------------------------------------------- 1 | package asyncjob_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/Azure/go-asyncjob" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDefinitionRendering(t *testing.T) { 11 | t.Parallel() 12 | 13 | renderGraph(t, SqlSummaryAsyncJobDefinition) 14 | } 15 | 16 | func TestDefinitionBuilder(t *testing.T) { 17 | t.Parallel() 18 | 19 | job := asyncjob.NewJobDefinition[*SqlSummaryJobLib]("sqlSummaryJob") 20 | notExistingTask := &asyncjob.StepDefinition[any]{} 21 | 22 | _, err := asyncjob.AddStep(job, "GetConnection", connectionStepFunc, asyncjob.ExecuteAfter(notExistingTask), asyncjob.WithContextEnrichment(EnrichContext)) 23 | assert.EqualError(t, err, "RefStepNotInJob: trying to reference to step \"\", but it is not registered in job") 24 | 25 | connTsk, err := asyncjob.AddStep(job, "GetConnection", connectionStepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 26 | assert.NoError(t, err) 27 | 28 | _, err = asyncjob.AddStep(job, "GetConnection", connectionStepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 29 | assert.EqualError(t, err, "AddExistingStep: trying to add step \"GetConnection\" to job definition, but it already exists") 30 | 31 | table1ClientTsk, err := asyncjob.StepAfter(job, "GetTableClient1", connTsk, tableClient1StepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 32 | assert.NoError(t, err) 33 | 34 | _, err = asyncjob.StepAfter(job, "GetTableClient1", connTsk, tableClient1StepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 35 | assert.EqualError(t, err, "AddExistingStep: trying to add step \"GetTableClient1\" to job definition, but it already exists") 36 | 37 | table2ClientTsk, err := asyncjob.StepAfter(job, "GetTableClient2", connTsk, tableClient2StepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 38 | assert.NoError(t, err) 39 | 40 | _, err = asyncjob.StepAfter(job, "QueryTable1", table1ClientTsk, queryTable1StepFunc, asyncjob.ExecuteAfter(notExistingTask), asyncjob.WithContextEnrichment(EnrichContext)) 41 | assert.EqualError(t, err, "RefStepNotInJob: trying to reference to step \"\", but it is not registered in job") 42 | 43 | query1Task, err := asyncjob.StepAfter(job, "QueryTable1", table1ClientTsk, queryTable1StepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 44 | assert.NoError(t, err) 45 | query2Task, err := asyncjob.StepAfter(job, "QueryTable2", table2ClientTsk, queryTable2StepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 46 | assert.NoError(t, err) 47 | 48 | _, err = asyncjob.StepAfterBoth(job, "Summarize", query1Task, query2Task, summarizeQueryResultStepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 49 | assert.NoError(t, err) 50 | 51 | _, err = asyncjob.StepAfterBoth(job, "Summarize", query1Task, query2Task, summarizeQueryResultStepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 52 | assert.EqualError(t, err, "AddExistingStep: trying to add step \"Summarize\" to job definition, but it already exists") 53 | 54 | _, err = asyncjob.StepAfterBoth(job, "Summarize1", query1Task, query1Task, summarizeQueryResultStepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 55 | assert.EqualError(t, err, "DuplicateInputParentStep: at least 2 input parentSteps are same") 56 | 57 | query3Task := &asyncjob.StepDefinition[*SqlQueryResult]{} 58 | _, err = asyncjob.StepAfterBoth(job, "Summarize2", query1Task, query3Task, summarizeQueryResultStepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 59 | assert.EqualError(t, err, "RefStepNotInJob: trying to reference to step \"\", but it is not registered in job") 60 | 61 | assert.False(t, job.Sealed()) 62 | job.Seal() 63 | assert.True(t, job.Sealed()) 64 | job.Seal() 65 | assert.True(t, job.Sealed()) 66 | 67 | _, err = asyncjob.AddStep(job, "GetConnectionAgain", connectionStepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 68 | assert.EqualError(t, err, "AddStepInSealedJob: trying to add step \"GetConnectionAgain\" to a sealed job definition") 69 | 70 | _, err = asyncjob.StepAfter(job, "QueryTable1Again", table1ClientTsk, queryTable1StepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 71 | assert.EqualError(t, err, "AddStepInSealedJob: trying to add step \"QueryTable1Again\" to a sealed job definition") 72 | 73 | _, err = asyncjob.StepAfterBoth(job, "SummarizeAgain", query1Task, query2Task, summarizeQueryResultStepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 74 | assert.EqualError(t, err, "AddStepInSealedJob: trying to add step \"SummarizeAgain\" to a sealed job definition") 75 | } 76 | -------------------------------------------------------------------------------- /step_definition.go: -------------------------------------------------------------------------------- 1 | package asyncjob 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/Azure/go-asyncjob/graph" 7 | ) 8 | 9 | type stepType string 10 | 11 | const stepTypeTask stepType = "task" 12 | const stepTypeRoot stepType = "root" 13 | 14 | // StepDefinitionMeta is the interface for a step definition 15 | type StepDefinitionMeta interface { 16 | 17 | // GetName return name of the step 18 | GetName() string 19 | 20 | // DependsOn return the list of step names that this step depends on 21 | DependsOn() []string 22 | 23 | // DotSpec used for generating graphviz graph 24 | DotSpec() *graph.DotNodeSpec 25 | 26 | // Instantiate a new step instance 27 | createStepInstance(context.Context, JobInstanceMeta) StepInstanceMeta 28 | } 29 | 30 | // StepDefinition defines a step and it's dependencies in a job definition. 31 | type StepDefinition[T any] struct { 32 | name string 33 | stepType stepType 34 | executionOptions *StepExecutionOptions 35 | instanceCreator func(context.Context, JobInstanceMeta) StepInstanceMeta 36 | } 37 | 38 | func newStepDefinition[T any](stepName string, stepType stepType, optionDecorators ...ExecutionOptionPreparer) *StepDefinition[T] { 39 | step := &StepDefinition[T]{ 40 | name: stepName, 41 | executionOptions: &StepExecutionOptions{}, 42 | stepType: stepType, 43 | } 44 | 45 | for _, decorator := range optionDecorators { 46 | step.executionOptions = decorator(step.executionOptions) 47 | } 48 | 49 | return step 50 | } 51 | 52 | func (sd *StepDefinition[T]) GetName() string { 53 | return sd.name 54 | } 55 | 56 | func (sd *StepDefinition[T]) DependsOn() []string { 57 | return sd.executionOptions.DependOn 58 | } 59 | 60 | func (sd *StepDefinition[T]) createStepInstance(ctx context.Context, jobInstance JobInstanceMeta) StepInstanceMeta { 61 | return sd.instanceCreator(ctx, jobInstance) 62 | } 63 | 64 | func (sd *StepDefinition[T]) DotSpec() *graph.DotNodeSpec { 65 | return &graph.DotNodeSpec{ 66 | Name: sd.GetName(), 67 | DisplayName: sd.GetName(), 68 | Shape: "box", 69 | Style: "filled", 70 | FillColor: "gray", 71 | Tooltip: "", 72 | } 73 | } 74 | 75 | func connectStepDefinition(stepFrom, stepTo StepDefinitionMeta) *graph.DotEdgeSpec { 76 | edgeSpec := &graph.DotEdgeSpec{ 77 | FromNodeName: stepFrom.GetName(), 78 | ToNodeName: stepTo.GetName(), 79 | Color: "black", 80 | Style: "bold", 81 | } 82 | 83 | return edgeSpec 84 | } 85 | -------------------------------------------------------------------------------- /step_exec_data.go: -------------------------------------------------------------------------------- 1 | package asyncjob 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // StepExecutionData would measure the step execution time and retry report. 8 | type StepExecutionData struct { 9 | StartTime time.Time 10 | Duration time.Duration 11 | Retried *RetryReport 12 | } 13 | 14 | // RetryReport would record the retry count (could extend to include each retry duration, ...) 15 | type RetryReport struct { 16 | Count uint 17 | } 18 | -------------------------------------------------------------------------------- /step_exec_options.go: -------------------------------------------------------------------------------- 1 | package asyncjob 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | type StepExecutionOptions struct { 9 | ErrorPolicy StepErrorPolicy 10 | RetryPolicy RetryPolicy 11 | ContextPolicy StepContextPolicy 12 | 13 | // dependencies that are not input. 14 | DependOn []string 15 | } 16 | 17 | type StepErrorPolicy struct{} 18 | 19 | type RetryPolicy interface { 20 | // ShouldRetry returns true if the error should be retried, and the duration to wait before retrying. 21 | // The int parameter is the retry count, first execution fail will invoke this with 0. 22 | ShouldRetry(error, uint) (bool, time.Duration) 23 | } 24 | 25 | // StepContextPolicy allows context enrichment before passing to step. 26 | // 27 | // With StepInstanceMeta you can access StepInstance, StepDefinition, JobInstance, JobDefinition. 28 | type StepContextPolicy func(context.Context, StepInstanceMeta) context.Context 29 | 30 | type ExecutionOptionPreparer func(*StepExecutionOptions) *StepExecutionOptions 31 | 32 | // Add precedence to a step. 33 | // 34 | // without taking input from it(use StepAfter/StepAfterBoth otherwise) 35 | func ExecuteAfter(step StepDefinitionMeta) ExecutionOptionPreparer { 36 | return func(options *StepExecutionOptions) *StepExecutionOptions { 37 | options.DependOn = append(options.DependOn, step.GetName()) 38 | return options 39 | } 40 | } 41 | 42 | // Allow retry of a step on error. 43 | func WithRetry(retryPolicy RetryPolicy) ExecutionOptionPreparer { 44 | return func(options *StepExecutionOptions) *StepExecutionOptions { 45 | options.RetryPolicy = retryPolicy 46 | return options 47 | } 48 | } 49 | 50 | func WithContextEnrichment(contextPolicy StepContextPolicy) ExecutionOptionPreparer { 51 | return func(options *StepExecutionOptions) *StepExecutionOptions { 52 | options.ContextPolicy = contextPolicy 53 | return options 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /step_instance.go: -------------------------------------------------------------------------------- 1 | package asyncjob 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/Azure/go-asyncjob/graph" 9 | "github.com/Azure/go-asynctask" 10 | ) 11 | 12 | type StepState string 13 | 14 | const StepStatePending StepState = "pending" 15 | const StepStateRunning StepState = "running" 16 | const StepStateFailed StepState = "failed" 17 | const StepStateCompleted StepState = "completed" 18 | 19 | // StepInstanceMeta is the interface for a step instance 20 | type StepInstanceMeta interface { 21 | GetName() string 22 | ExecutionData() *StepExecutionData 23 | GetState() StepState 24 | GetJobInstance() JobInstanceMeta 25 | GetStepDefinition() StepDefinitionMeta 26 | Waitable() asynctask.Waitable 27 | 28 | DotSpec() *graph.DotNodeSpec 29 | } 30 | 31 | // StepInstance is the instance of a step, within a job instance. 32 | type StepInstance[T any] struct { 33 | Definition *StepDefinition[T] 34 | JobInstance JobInstanceMeta 35 | 36 | task *asynctask.Task[T] 37 | state StepState 38 | executionData *StepExecutionData 39 | } 40 | 41 | func newStepInstance[T any](stepDefinition *StepDefinition[T], jobInstance JobInstanceMeta) *StepInstance[T] { 42 | return &StepInstance[T]{ 43 | Definition: stepDefinition, 44 | JobInstance: jobInstance, 45 | executionData: &StepExecutionData{}, 46 | state: StepStatePending, 47 | } 48 | } 49 | 50 | func (si *StepInstance[T]) GetJobInstance() JobInstanceMeta { 51 | return si.JobInstance 52 | } 53 | 54 | func (si *StepInstance[T]) GetStepDefinition() StepDefinitionMeta { 55 | return si.Definition 56 | } 57 | 58 | func (si *StepInstance[T]) Waitable() asynctask.Waitable { 59 | return si.task 60 | } 61 | 62 | func (si *StepInstance[T]) GetName() string { 63 | return si.Definition.GetName() 64 | } 65 | 66 | func (si *StepInstance[T]) GetState() StepState { 67 | return si.state 68 | } 69 | 70 | func (si *StepInstance[T]) EnrichContext(ctx context.Context) (result context.Context) { 71 | result = ctx 72 | if si.Definition.executionOptions.ContextPolicy != nil { 73 | // TODO: bubble up the error somehow 74 | defer func() { 75 | if r := recover(); r != nil { 76 | fmt.Println("Recovered in EnrichContext", r) 77 | } 78 | }() 79 | result = si.Definition.executionOptions.ContextPolicy(ctx, si) 80 | } 81 | 82 | return result 83 | } 84 | 85 | func (si *StepInstance[T]) ExecutionData() *StepExecutionData { 86 | return si.executionData 87 | } 88 | 89 | func (si *StepInstance[T]) DotSpec() *graph.DotNodeSpec { 90 | shape := "hexagon" 91 | if si.Definition.stepType == stepTypeRoot { 92 | shape = "triangle" 93 | } 94 | 95 | color := "gray" 96 | switch si.state { 97 | case StepStatePending: 98 | color = "gray" 99 | case StepStateRunning: 100 | color = "yellow" 101 | case StepStateCompleted: 102 | color = "green" 103 | case StepStateFailed: 104 | color = "red" 105 | } 106 | 107 | tooltip := "" 108 | if si.state != StepStatePending && si.executionData != nil { 109 | tooltip = fmt.Sprintf("State: %s\\nStartAt: %s\\nDuration: %s", si.state, si.executionData.StartTime.Format(time.RFC3339Nano), si.executionData.Duration) 110 | } 111 | 112 | return &graph.DotNodeSpec{ 113 | Name: si.GetName(), 114 | DisplayName: si.GetName(), 115 | Shape: shape, 116 | Style: "filled", 117 | FillColor: color, 118 | Tooltip: tooltip, 119 | } 120 | } 121 | 122 | func connectStepInstance(stepFrom, stepTo StepInstanceMeta) *graph.DotEdgeSpec { 123 | edgeSpec := &graph.DotEdgeSpec{ 124 | FromNodeName: stepFrom.GetName(), 125 | ToNodeName: stepTo.GetName(), 126 | Color: "black", 127 | Style: "bold", 128 | } 129 | 130 | // update edge color, tooltip if NodeTo is started already. 131 | if stepTo.GetState() != StepStatePending { 132 | executionData := stepTo.ExecutionData() 133 | edgeSpec.Tooltip = fmt.Sprintf("Time: %s", executionData.StartTime.Format(time.RFC3339Nano)) 134 | } 135 | 136 | fromNodeState := stepFrom.GetState() 137 | if fromNodeState == StepStateCompleted { 138 | edgeSpec.Color = "green" 139 | } else if fromNodeState == StepStateFailed { 140 | edgeSpec.Color = "red" 141 | } 142 | 143 | return edgeSpec 144 | } 145 | -------------------------------------------------------------------------------- /test_joblib_test.go: -------------------------------------------------------------------------------- 1 | package asyncjob_test 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "sync" 7 | "testing" 8 | "time" 9 | 10 | "github.com/Azure/go-asyncjob" 11 | "github.com/Azure/go-asynctask" 12 | ) 13 | 14 | type testingLoggerKey string 15 | 16 | const testLoggingContextKey testingLoggerKey = "test-logging" 17 | 18 | // SqlSummaryAsyncJobDefinition is the job definition for the SqlSummaryJobLib 19 | // 20 | // JobDefinition fit perfectly in init() function 21 | var SqlSummaryAsyncJobDefinition *asyncjob.JobDefinitionWithResult[*SqlSummaryJobLib, *SummarizedResult] 22 | 23 | func init() { 24 | var err error 25 | SqlSummaryAsyncJobDefinition, err = BuildJobWithResult(map[string]asyncjob.RetryPolicy{}) 26 | if err != nil { 27 | panic(err) 28 | } 29 | 30 | SqlSummaryAsyncJobDefinition.Seal() 31 | } 32 | 33 | type SqlSummaryJobLib struct { 34 | Params *SqlSummaryJobParameters 35 | 36 | // assume you have some state that you want to share between steps 37 | // you can use a mutex to protect the data writen between different steps 38 | // this kind of error is not fault of this library. 39 | data map[string]interface{} 40 | mutex sync.Mutex 41 | } 42 | 43 | func NewSqlJobLib(params *SqlSummaryJobParameters) *SqlSummaryJobLib { 44 | return &SqlSummaryJobLib{ 45 | Params: params, 46 | 47 | data: make(map[string]interface{}), 48 | mutex: sync.Mutex{}, 49 | } 50 | } 51 | 52 | func connectionStepFunc(sql *SqlSummaryJobLib) asynctask.AsyncFunc[*SqlConnection] { 53 | return func(ctx context.Context) (*SqlConnection, error) { 54 | return sql.GetConnection(ctx, &sql.Params.ServerName) 55 | } 56 | } 57 | 58 | func checkAuthStepFunc(sql *SqlSummaryJobLib) asynctask.AsyncFunc[interface{}] { 59 | return asynctask.ActionToFunc(func(ctx context.Context) error { 60 | return sql.CheckAuth(ctx) 61 | }) 62 | } 63 | 64 | func tableClient1StepFunc(sql *SqlSummaryJobLib) asynctask.ContinueFunc[*SqlConnection, *SqlTableClient] { 65 | return func(ctx context.Context, conn *SqlConnection) (*SqlTableClient, error) { 66 | return sql.GetTableClient(ctx, conn, &sql.Params.Table1) 67 | } 68 | } 69 | 70 | func tableClient2StepFunc(sql *SqlSummaryJobLib) asynctask.ContinueFunc[*SqlConnection, *SqlTableClient] { 71 | return func(ctx context.Context, conn *SqlConnection) (*SqlTableClient, error) { 72 | return sql.GetTableClient(ctx, conn, &sql.Params.Table2) 73 | } 74 | } 75 | 76 | func queryTable1StepFunc(sql *SqlSummaryJobLib) asynctask.ContinueFunc[*SqlTableClient, *SqlQueryResult] { 77 | return func(ctx context.Context, tableClient *SqlTableClient) (*SqlQueryResult, error) { 78 | return sql.ExecuteQuery(ctx, tableClient, &sql.Params.Query1) 79 | } 80 | } 81 | 82 | func queryTable2StepFunc(sql *SqlSummaryJobLib) asynctask.ContinueFunc[*SqlTableClient, *SqlQueryResult] { 83 | return func(ctx context.Context, tableClient *SqlTableClient) (*SqlQueryResult, error) { 84 | return sql.ExecuteQuery(ctx, tableClient, &sql.Params.Query2) 85 | } 86 | } 87 | 88 | func summarizeQueryResultStepFunc(sql *SqlSummaryJobLib) asynctask.AfterBothFunc[*SqlQueryResult, *SqlQueryResult, *SummarizedResult] { 89 | return func(ctx context.Context, query1Result *SqlQueryResult, query2Result *SqlQueryResult) (*SummarizedResult, error) { 90 | return sql.SummarizeQueryResult(ctx, query1Result, query2Result) 91 | } 92 | } 93 | 94 | func emailNotificationStepFunc(sql *SqlSummaryJobLib) asynctask.AsyncFunc[interface{}] { 95 | return asynctask.ActionToFunc(func(ctx context.Context) error { 96 | return sql.EmailNotification(ctx) 97 | }) 98 | } 99 | 100 | func BuildJob(retryPolicies map[string]asyncjob.RetryPolicy) (*asyncjob.JobDefinition[*SqlSummaryJobLib], error) { 101 | job := asyncjob.NewJobDefinition[*SqlSummaryJobLib]("sqlSummaryJob") 102 | 103 | connTsk, err := asyncjob.AddStep(job, "GetConnection", connectionStepFunc, asyncjob.WithRetry(retryPolicies["GetConnection"]), asyncjob.WithContextEnrichment(EnrichContext)) 104 | if err != nil { 105 | return nil, fmt.Errorf("error adding step GetConnection: %w", err) 106 | } 107 | 108 | checkAuthTask, err := asyncjob.AddStep(job, "CheckAuth", checkAuthStepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 109 | if err != nil { 110 | return nil, fmt.Errorf("error adding step CheckAuth: %w", err) 111 | } 112 | 113 | table1ClientTsk, err := asyncjob.StepAfter(job, "GetTableClient1", connTsk, tableClient1StepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 114 | if err != nil { 115 | return nil, fmt.Errorf("error adding step GetTableClient1: %w", err) 116 | } 117 | 118 | qery1ResultTsk, err := asyncjob.StepAfter(job, "QueryTable1", table1ClientTsk, queryTable1StepFunc, asyncjob.WithRetry(retryPolicies["QueryTable1"]), asyncjob.ExecuteAfter(checkAuthTask), asyncjob.WithContextEnrichment(EnrichContext)) 119 | if err != nil { 120 | return nil, fmt.Errorf("error adding step QueryTable1: %w", err) 121 | } 122 | 123 | table2ClientTsk, err := asyncjob.StepAfter(job, "GetTableClient2", connTsk, tableClient2StepFunc, asyncjob.WithContextEnrichment(EnrichContext)) 124 | if err != nil { 125 | return nil, fmt.Errorf("error adding step GetTableClient2: %w", err) 126 | } 127 | 128 | qery2ResultTsk, err := asyncjob.StepAfter(job, "QueryTable2", table2ClientTsk, queryTable2StepFunc, asyncjob.WithRetry(retryPolicies["QueryTable2"]), asyncjob.ExecuteAfter(checkAuthTask), asyncjob.WithContextEnrichment(EnrichContext)) 129 | if err != nil { 130 | return nil, fmt.Errorf("error adding step QueryTable2: %w", err) 131 | } 132 | 133 | summaryTsk, err := asyncjob.StepAfterBoth(job, "Summarize", qery1ResultTsk, qery2ResultTsk, summarizeQueryResultStepFunc, asyncjob.WithRetry(retryPolicies["Summarize"]), asyncjob.WithContextEnrichment(EnrichContext)) 134 | if err != nil { 135 | return nil, fmt.Errorf("error adding step Summarize: %w", err) 136 | } 137 | 138 | _, err = asyncjob.AddStep(job, "EmailNotification", emailNotificationStepFunc, asyncjob.ExecuteAfter(summaryTsk), asyncjob.WithContextEnrichment(EnrichContext)) 139 | if err != nil { 140 | return nil, fmt.Errorf("error adding step EmailNotification: %w", err) 141 | } 142 | return job, nil 143 | } 144 | 145 | func BuildJobWithResult(retryPolicies map[string]asyncjob.RetryPolicy) (*asyncjob.JobDefinitionWithResult[*SqlSummaryJobLib, *SummarizedResult], error) { 146 | job, err := BuildJob(retryPolicies) 147 | if err != nil { 148 | return nil, err 149 | } 150 | 151 | summaryStepMeta, ok := job.GetStep("Summarize") 152 | if !ok { 153 | return nil, fmt.Errorf("step Summarize not found") 154 | } 155 | summaryStep, ok := summaryStepMeta.(*asyncjob.StepDefinition[*SummarizedResult]) 156 | if !ok { 157 | return nil, fmt.Errorf("step Summarize have different generic type parameter: %T", summaryStepMeta) 158 | } 159 | return asyncjob.JobWithResult(job, summaryStep) 160 | } 161 | 162 | type SqlSummaryJobParameters struct { 163 | ServerName string 164 | Table1 string 165 | Query1 string 166 | Table2 string 167 | Query2 string 168 | ErrorInjection map[string]func() error 169 | PanicInjection map[string]bool 170 | } 171 | 172 | type SqlConnection struct { 173 | ServerName string 174 | } 175 | 176 | type SqlTableClient struct { 177 | ServerName string 178 | TableName string 179 | } 180 | 181 | type SqlQueryResult struct { 182 | Data map[string]interface{} 183 | } 184 | 185 | type SummarizedResult struct { 186 | QueryResult1 map[string]interface{} 187 | QueryResult2 map[string]interface{} 188 | } 189 | 190 | func (sql *SqlSummaryJobLib) GetConnection(ctx context.Context, serverName *string) (*SqlConnection, error) { 191 | sql.Logging(ctx, "GetConnection") 192 | if sql.Params.ErrorInjection != nil { 193 | if errFunc, ok := sql.Params.ErrorInjection["GetConnection"]; ok { 194 | if err := errFunc(); err != nil { 195 | return nil, err 196 | } 197 | } 198 | } 199 | return &SqlConnection{ServerName: *serverName}, nil 200 | } 201 | 202 | func (sql *SqlSummaryJobLib) GetTableClient(ctx context.Context, conn *SqlConnection, tableName *string) (*SqlTableClient, error) { 203 | sql.Logging(ctx, fmt.Sprintf("GetTableClient with tableName: %s", *tableName)) 204 | injectionKey := fmt.Sprintf("GetTableClient.%s.%s", conn.ServerName, *tableName) 205 | if sql.Params.PanicInjection != nil { 206 | if shouldPanic, ok := sql.Params.PanicInjection[injectionKey]; ok && shouldPanic { 207 | panic("as you wish") 208 | } 209 | } 210 | if sql.Params.ErrorInjection != nil { 211 | if errFunc, ok := sql.Params.ErrorInjection[injectionKey]; ok { 212 | if err := errFunc(); err != nil { 213 | return nil, err 214 | } 215 | } 216 | } 217 | return &SqlTableClient{ServerName: conn.ServerName, TableName: *tableName}, nil 218 | } 219 | 220 | func (sql *SqlSummaryJobLib) CheckAuth(ctx context.Context) error { 221 | sql.Logging(ctx, "CheckAuth") 222 | injectionKey := "CheckAuth" 223 | if sql.Params.PanicInjection != nil { 224 | if shouldPanic, ok := sql.Params.PanicInjection[injectionKey]; ok && shouldPanic { 225 | panic("as you wish") 226 | } 227 | } 228 | if sql.Params.ErrorInjection != nil { 229 | if errFunc, ok := sql.Params.ErrorInjection[injectionKey]; ok { 230 | if err := errFunc(); err != nil { 231 | return err 232 | } 233 | } 234 | } 235 | return nil 236 | } 237 | 238 | func (sql *SqlSummaryJobLib) ExecuteQuery(ctx context.Context, tableClient *SqlTableClient, queryString *string) (*SqlQueryResult, error) { 239 | sql.Logging(ctx, fmt.Sprintf("ExecuteQuery: %s", *queryString)) 240 | injectionKey := fmt.Sprintf("ExecuteQuery.%s.%s.%s", tableClient.ServerName, tableClient.TableName, *queryString) 241 | if sql.Params.PanicInjection != nil { 242 | if shouldPanic, ok := sql.Params.PanicInjection[injectionKey]; ok && shouldPanic { 243 | panic("as you wish") 244 | } 245 | } 246 | if sql.Params.ErrorInjection != nil { 247 | if errFunc, ok := sql.Params.ErrorInjection[injectionKey]; ok { 248 | if err := errFunc(); err != nil { 249 | return nil, err 250 | } 251 | } 252 | } 253 | 254 | // assume you have some state that you want to share between steps 255 | // you can use a mutex to protect the data writen between different steps 256 | // uncomment the mutex code, and run test with --race 257 | sql.mutex.Lock() 258 | defer sql.mutex.Unlock() 259 | sql.data["serverName"] = tableClient.ServerName 260 | sql.data[tableClient.TableName] = *queryString 261 | 262 | return &SqlQueryResult{Data: map[string]interface{}{"serverName": tableClient.ServerName, "tableName": tableClient.TableName, "queryName": *queryString}}, nil 263 | } 264 | 265 | func (sql *SqlSummaryJobLib) SummarizeQueryResult(ctx context.Context, result1 *SqlQueryResult, result2 *SqlQueryResult) (*SummarizedResult, error) { 266 | sql.Logging(ctx, "SummarizeQueryResult") 267 | injectionKey := "SummarizeQueryResult" 268 | if sql.Params.PanicInjection != nil { 269 | if shouldPanic, ok := sql.Params.PanicInjection[injectionKey]; ok && shouldPanic { 270 | panic("as you wish") 271 | } 272 | } 273 | if sql.Params.ErrorInjection != nil { 274 | if errFunc, ok := sql.Params.ErrorInjection[injectionKey]; ok { 275 | if err := errFunc(); err != nil { 276 | return nil, err 277 | } 278 | } 279 | } 280 | return &SummarizedResult{QueryResult1: result1.Data, QueryResult2: result2.Data}, nil 281 | } 282 | 283 | func (sql *SqlSummaryJobLib) EmailNotification(ctx context.Context) error { 284 | sql.Logging(ctx, "EmailNotification") 285 | return nil 286 | } 287 | 288 | func (sql *SqlSummaryJobLib) Logging(ctx context.Context, msg string) { 289 | if tI := ctx.Value(testLoggingContextKey); tI != nil { 290 | t := tI.(*testing.T) 291 | 292 | jobName := ctx.Value("asyncjob.jobName") 293 | jobId := ctx.Value("asyncjob.jobId") 294 | stepName := ctx.Value("asyncjob.stepName") 295 | 296 | t.Logf("[Job: %s-%s, Step: %s] %s", jobName, jobId, stepName, msg) 297 | 298 | } else { 299 | fmt.Println(msg) 300 | } 301 | } 302 | 303 | func EnrichContext(ctx context.Context, instanceMeta asyncjob.StepInstanceMeta) context.Context { 304 | ctx = context.WithValue(ctx, "asyncjob.jobName", instanceMeta.GetJobInstance().GetJobDefinition().GetName()) 305 | ctx = context.WithValue(ctx, "asyncjob.jobId", instanceMeta.GetJobInstance().GetJobInstanceId()) 306 | ctx = context.WithValue(ctx, "asyncjob.stepName", instanceMeta.GetStepDefinition().GetName()) 307 | return ctx 308 | } 309 | 310 | type linearRetryPolicy struct { 311 | sleepInterval time.Duration 312 | maxRetryCount uint 313 | } 314 | 315 | func newLinearRetryPolicy(sleepInterval time.Duration, maxRetryCount uint) asyncjob.RetryPolicy { 316 | return &linearRetryPolicy{ 317 | sleepInterval: sleepInterval, 318 | maxRetryCount: maxRetryCount, 319 | } 320 | } 321 | 322 | func (lrp *linearRetryPolicy) ShouldRetry(_ error, tried uint) (bool, time.Duration) { 323 | if tried < lrp.maxRetryCount { 324 | return true, lrp.sleepInterval 325 | } 326 | 327 | return false, time.Duration(0) 328 | } 329 | --------------------------------------------------------------------------------