diff --git a/.autover/autover.json b/.autover/autover.json index 02f2ad0db..88c3c1795 100644 --- a/.autover/autover.json +++ b/.autover/autover.json @@ -49,7 +49,10 @@ }, { "Name": "Amazon.Lambda.DurableExecution", - "Path": "Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj", + "Paths": [ + "Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj", + "Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj" + ], "PrereleaseLabel": "preview" }, { diff --git a/.autover/changes/durable-execution-analyzers.json b/.autover/changes/durable-execution-analyzers.json new file mode 100644 index 000000000..4689a8839 --- /dev/null +++ b/.autover/changes/durable-execution-analyzers.json @@ -0,0 +1,11 @@ +{ + "Projects": [ + { + "Name": "Amazon.Lambda.DurableExecution", + "Type": "Minor", + "ChangelogMessages": [ + "Add Roslyn analyzers (DE001-DE004) that catch common durable-execution authoring mistakes at build time, bundled in the package so they activate automatically for consumers. DE001 (Warning) flags non-deterministic APIs (DateTime.Now, Guid.NewGuid(), Random, Stopwatch, Environment.TickCount, crypto RNG) used in workflow code outside a step. DE002 (Warning) flags a durable operation invoked inside a step body via the captured outer IDurableContext. DE003 (Warning) flags mutation of a captured outer-scope variable inside a durable-operation delegate. DE004 (Info) suggests ParallelAsync/MapAsync over Task.WhenAll/Task.WhenAny for durable tasks. DE001 and DE004 include code fixes. Preview." + ] + } + ] +} diff --git a/.github/workflows/auto-update-Dockerfiles.yml b/.github/workflows/auto-update-Dockerfiles.yml index d2fb2b9d6..64095bb1c 100644 --- a/.github/workflows/auto-update-Dockerfiles.yml +++ b/.github/workflows/auto-update-Dockerfiles.yml @@ -1,8 +1,6 @@ name: Auto-Update Lambda Dockerfiles Daily -permissions: - contents: write - pull-requests: write +permissions: {} on: # Run daily at midnight UTC @@ -11,9 +9,17 @@ on: # Allows to run this workflow manually from the Actions tab for testing workflow_dispatch: +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: auto-update: + name: Auto-update Dockerfiles and open PR runs-on: ubuntu-latest + permissions: + contents: write # to push the daily Dockerfile update branch + pull-requests: write # to open the update PR and label it env: NET_8_AMD64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/amd64/Dockerfile" NET_8_ARM64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/arm64/Dockerfile" @@ -39,7 +45,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "8" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 8 AMD64 update - No version detected" } @@ -53,7 +59,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "8" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 8 ARM64 update - No version detected" } @@ -67,7 +73,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "9" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 9 AMD64 update - No version detected" } @@ -81,7 +87,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "9" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 9 ARM64 update - No version detected" } @@ -95,7 +101,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "10" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 10 AMD64 update - No version detected" } @@ -109,7 +115,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "10" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 10 ARM64 update - No version detected" } @@ -123,7 +129,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "11" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 11 AMD64 update - No version detected" } @@ -137,7 +143,7 @@ jobs: run: | $version = & "./LambdaRuntimeDockerfiles/get-latest-aspnet-versions.ps1" -MajorVersion "11" if (-not [string]::IsNullOrEmpty($version)) { - & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion $version + & "./LambdaRuntimeDockerfiles/update-dockerfile.ps1" -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion $version } else { Write-Host "Skipping .NET 11 ARM64 update - No version detected" } @@ -196,7 +202,7 @@ jobs: - name: Create Pull Request id: pull-request if: ${{ steps.commit-push.outputs.CHANGES_MADE == 'true' }} - uses: repo-sync/pull-request@v2 + uses: repo-sync/pull-request@7e79a9f5dc3ad0ce53138f01df2fad14a04831c5 # v2 with: source_branch: ${{ steps.commit-push.outputs.BRANCH }} destination_branch: "dev" @@ -226,13 +232,15 @@ jobs: # Add "Release Not Needed" label to the PR - name: Add Release Not Needed label if: ${{ steps.pull-request.outputs.pr_number }} - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + PR_NUMBER: ${{ steps.pull-request.outputs.pr_number }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: ${{ steps.pull-request.outputs.pr_number }}, + issue_number: Number(process.env.PR_NUMBER), labels: ['Release Not Needed'] }) diff --git a/.github/workflows/aws-ci.yml b/.github/workflows/aws-ci.yml index dc8491927..83afe378f 100644 --- a/.github/workflows/aws-ci.yml +++ b/.github/workflows/aws-ci.yml @@ -8,12 +8,18 @@ on: - dev - "feature/**" -permissions: - id-token: write +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: run-ci: + name: Run CI runs-on: ubuntu-latest + permissions: + id-token: write # to assume AWS roles via OIDC steps: - name: Configure Load Balancer Credentials uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 # v6.0.0 @@ -24,8 +30,13 @@ jobs: - name: Invoke Load Balancer Lambda id: lambda shell: pwsh + env: + LOAD_BALANCER_LAMBDA_NAME: ${{ secrets.CI_TESTING_LOAD_BALANCER_LAMBDA_NAME }} + TEST_RUNNER_ACCOUNT_ROLES: ${{ secrets.CI_TEST_RUNNER_ACCOUNT_ROLES }} + CODE_BUILD_PROJECT_NAME: ${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }} + BRANCH: ${{ github.sha }} run: | - aws lambda invoke response.json --function-name "${{ secrets.CI_TESTING_LOAD_BALANCER_LAMBDA_NAME }}" --cli-binary-format raw-in-base64-out --payload '{"Roles": "${{ secrets.CI_TEST_RUNNER_ACCOUNT_ROLES }}", "ProjectName": "${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }}", "Branch": "${{ github.sha }}"}' + aws lambda invoke response.json --function-name "$env:LOAD_BALANCER_LAMBDA_NAME" --cli-binary-format raw-in-base64-out --payload "{`"Roles`": `"$env:TEST_RUNNER_ACCOUNT_ROLES`", `"ProjectName`": `"$env:CODE_BUILD_PROJECT_NAME`", `"Branch`": `"$env:BRANCH`"}" $roleArn=$(cat ./response.json) "roleArn=$($roleArn -replace '"', '')" >> $env:GITHUB_OUTPUT - name: Configure Test Runner Credentials @@ -36,7 +47,7 @@ jobs: aws-region: us-west-2 - name: Run Tests on AWS id: codebuild - uses: aws-actions/aws-codebuild-run-build@v1 + uses: aws-actions/aws-codebuild-run-build@4d15a47425739ac2296ba5e7eee3bdd4bfbdd767 # v1.0.18 with: project-name: ${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }} - name: Configure Test Sweeper Lambda Credentials @@ -49,10 +60,15 @@ jobs: - name: Invoke Test Sweeper Lambda if: always() shell: pwsh + env: + TEST_SWEEPER_LAMBDA_NAME: ${{ secrets.CI_TESTING_TEST_SWEEPER_LAMBDA_NAME }} + CODE_BUILD_PROJECT_NAME: ${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }} run: | - aws lambda invoke response.json --function-name "${{ secrets.CI_TESTING_TEST_SWEEPER_LAMBDA_NAME }}" --cli-binary-format raw-in-base64-out --payload '{"Tags": "aws-repo=${{ secrets.CI_TESTING_CODE_BUILD_PROJECT_NAME }}"}' + aws lambda invoke response.json --function-name "$env:TEST_SWEEPER_LAMBDA_NAME" --cli-binary-format raw-in-base64-out --payload "{`"Tags`": `"aws-repo=$env:CODE_BUILD_PROJECT_NAME`"}" - name: CodeBuild Link shell: pwsh + env: + BUILD_ID: ${{ steps.codebuild.outputs.aws-build-id }} run: | - $buildId = "${{ steps.codebuild.outputs.aws-build-id }}" + $buildId = "$env:BUILD_ID" echo $buildId diff --git a/.github/workflows/build-lambda-runtime-dockerfiles.yml b/.github/workflows/build-lambda-runtime-dockerfiles.yml index 425181175..9f30c61ee 100644 --- a/.github/workflows/build-lambda-runtime-dockerfiles.yml +++ b/.github/workflows/build-lambda-runtime-dockerfiles.yml @@ -8,13 +8,18 @@ on: paths: - "LambdaRuntimeDockerfiles/**" -permissions: - contents: read +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true jobs: build-runtime-images: name: Build runtime image (${{ matrix.name }}) runs-on: ubuntu-latest + permissions: + contents: read # to check out the repository and build the Dockerfiles strategy: fail-fast: false matrix: @@ -45,16 +50,18 @@ jobs: platform: linux/arm64 steps: - - uses: actions/checkout@85e6279cec87321a52edac9c87bce653a07cf6c2 #v4.2.2 + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@06116385d9baf250c9f4dcb4858b16962ea869c3 # v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4 - name: Build ${{ matrix.name }} - uses: docker/build-push-action@v7 + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 with: context: . file: ${{ matrix.dockerfile }} diff --git a/.github/workflows/change-file-in-pr.yml b/.github/workflows/change-file-in-pr.yml index 51a2fb001..7ff26b6a5 100644 --- a/.github/workflows/change-file-in-pr.yml +++ b/.github/workflows/change-file-in-pr.yml @@ -4,24 +4,36 @@ on: pull_request: types: [opened, synchronize, reopened, labeled] +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number }} + cancel-in-progress: true + jobs: check-files-in-directory: if: ${{ !contains(github.event.pull_request.labels.*.name, 'Release Not Needed') && !contains(github.event.pull_request.labels.*.name, 'Release PR') }} name: Change File Included in PR runs-on: ubuntu-latest + permissions: + contents: read # to check out the repository and list changed files steps: - name: Checkout PR code - uses: actions/checkout@v6 + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - name: Get List of Changed Files id: changed-files - uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 + uses: tj-actions/changed-files@22103cc46bda19c2b464ffe86db46df6922fd323 # v47.0.5 - name: Check for Change File(s) in .autover/changes/ + env: + ALL_CHANGED_FILES: ${{ steps.changed-files.outputs.all_changed_files }} run: | DIRECTORY=".autover/changes/" - if echo "${{ steps.changed-files.outputs.all_changed_files }}" | grep -q "$DIRECTORY"; then + if echo "$ALL_CHANGED_FILES" | grep -q "$DIRECTORY"; then echo "✅ One or more change files in '$DIRECTORY' are included in this PR." else echo "❌ No change files in '$DIRECTORY' are included in this PR." diff --git a/.github/workflows/closed-issue-message.yml b/.github/workflows/closed-issue-message.yml index 3c394caaa..32c8f8706 100644 --- a/.github/workflows/closed-issue-message.yml +++ b/.github/workflows/closed-issue-message.yml @@ -3,18 +3,24 @@ on: issues: types: [closed] -permissions: - issues: write +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false jobs: auto_comment: + name: Comment on closed issue runs-on: ubuntu-latest + permissions: + issues: write # to comment on the closed issue steps: - - uses: aws-actions/closed-issue-message@v2 + - uses: aws-actions/closed-issue-message@10aaf6366131b673a7c8b7742f8b3849f1d44f18 # v2 with: # These inputs are both required repo-token: "${{ secrets.GITHUB_TOKEN }}" message: | - Comments on closed issues are hard for our team to see. - If you need more assistance, please either tag a team member or open a new issue that references this one. + Comments on closed issues are hard for our team to see. + If you need more assistance, please either tag a team member or open a new issue that references this one. If you wish to keep having a conversation with other community members under this issue feel free to do so. diff --git a/.github/workflows/create-release-pr.yml b/.github/workflows/create-release-pr.yml index 02c7fb641..d47599303 100644 --- a/.github/workflows/create-release-pr.yml +++ b/.github/workflows/create-release-pr.yml @@ -11,14 +11,19 @@ on: type: string required: false -permissions: - id-token: write - repository-projects: read +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false jobs: release-pr: name: Release PR runs-on: ubuntu-latest + permissions: + id-token: write # to assume AWS roles via OIDC + repository-projects: read # to read project metadata when creating the release PR env: INPUT_OVERRIDE_VERSION: ${{ github.event.inputs.OVERRIDE_VERSION }} @@ -97,10 +102,11 @@ jobs: run: autover changelog # Push the release branch up as well as the created tag - name: Push Changes + env: + BRANCH: ${{ steps.create-release-branch.outputs.BRANCH }} run: | - branch=${{ steps.create-release-branch.outputs.BRANCH }} - git push origin $branch - git push origin $branch --tags + git push origin "$BRANCH" + git push origin "$BRANCH" --tags # Get the release name that will be used to create a PR - name: Read Release Name id: read-release-name @@ -117,7 +123,10 @@ jobs: - name: Create Pull Request env: GITHUB_TOKEN: ${{ env.FG_PAT }} + VERSION: ${{ steps.read-release-name.outputs.VERSION }} + CHANGELOG: ${{ steps.read-changelog.outputs.CHANGELOG }} + BRANCH: ${{ steps.create-release-branch.outputs.BRANCH }} run: | gh label create "Release PR" --description "A Release PR that includes versioning and changelog changes" -c "#FF0000" -f - pr_url="$(gh pr create --title "${{ steps.read-release-name.outputs.VERSION }}" --label "Release PR" --body "${{ steps.read-changelog.outputs.CHANGELOG }}" --base dev --head ${{ steps.create-release-branch.outputs.BRANCH }})" + pr_url="$(gh pr create --title "$VERSION" --label "Release PR" --body "$CHANGELOG" --base dev --head "$BRANCH")" diff --git a/.github/workflows/handle-stale-discussions.yml b/.github/workflows/handle-stale-discussions.yml index 534dc6584..e8494e64f 100644 --- a/.github/workflows/handle-stale-discussions.yml +++ b/.github/workflows/handle-stale-discussions.yml @@ -5,14 +5,20 @@ on: discussion_comment: types: [created] +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: handle-stale-discussions: name: Handle stale discussions runs-on: ubuntu-latest permissions: - discussions: write + discussions: write # to mark and close stale discussions steps: - name: Stale discussions action - uses: aws-github-ops/handle-stale-discussions@v1.6.0 + uses: aws-github-ops/handle-stale-discussions@c0beee451a5d33d9c8f048a6d4e7c856b5422544 # v1.6.0 env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} diff --git a/.github/workflows/issue-regression-labeler.yml b/.github/workflows/issue-regression-labeler.yml index 7c335bcfe..4f8695a2d 100644 --- a/.github/workflows/issue-regression-labeler.yml +++ b/.github/workflows/issue-regression-labeler.yml @@ -3,16 +3,21 @@ name: issue-regression-label on: issues: types: [opened, edited] +permissions: {} +concurrency: + group: ${{ github.workflow }}-${{ github.event.issue.number }} + cancel-in-progress: true jobs: add-regression-label: + name: Add potential-regression label runs-on: ubuntu-latest permissions: - issues: write + issues: write # to add or remove the potential-regression label steps: - name: Fetch template body id: check_regression - uses: actions/github-script@v8 - env: + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} TEMPLATE_BODY: ${{ github.event.issue.body }} with: @@ -24,9 +29,11 @@ jobs: - name: Manage regression label env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + IS_REGRESSION: ${{ steps.check_regression.outputs.is_regression }} + ISSUE_NUMBER: ${{ github.event.issue.number }} run: | - if [ "${{ steps.check_regression.outputs.is_regression }}" == "true" ]; then - gh issue edit ${{ github.event.issue.number }} --add-label "potential-regression" -R ${{ github.repository }} + if [ "$IS_REGRESSION" == "true" ]; then + gh issue edit "$ISSUE_NUMBER" --add-label "potential-regression" -R "$GITHUB_REPOSITORY" else - gh issue edit ${{ github.event.issue.number }} --remove-label "potential-regression" -R ${{ github.repository }} + gh issue edit "$ISSUE_NUMBER" --remove-label "potential-regression" -R "$GITHUB_REPOSITORY" fi diff --git a/.github/workflows/semgrep-analysis.yml b/.github/workflows/semgrep-analysis.yml index da6e998de..b140b7159 100644 --- a/.github/workflows/semgrep-analysis.yml +++ b/.github/workflows/semgrep-analysis.yml @@ -13,19 +13,28 @@ on: # Manually trigger the workflow workflow_dispatch: +permissions: {} + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + jobs: semgrep: name: Scan permissions: - security-events: write + contents: read # to check out the repository + security-events: write # to upload SARIF results to the code scanning dashboard runs-on: ubuntu-latest container: - image: returntocorp/semgrep + image: returntocorp/semgrep@sha256:06938c1f365d3f67b8cedd8bc117607ae64253f88a0e768e9da9408548927dd6 # latest # Skip any PR created by dependabot to avoid permission issues if: (github.actor != 'dependabot[bot]') steps: # Fetch project source - - uses: actions/checkout@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + persist-credentials: false - run: semgrep ci --sarif > semgrep.sarif env: diff --git a/.github/workflows/stale_issues.yml b/.github/workflows/stale_issues.yml index 90932eea5..2e3866233 100644 --- a/.github/workflows/stale_issues.yml +++ b/.github/workflows/stale_issues.yml @@ -5,16 +5,21 @@ on: schedule: - cron: "0 0 * * *" -permissions: - issues: write - pull-requests: write +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false jobs: cleanup: runs-on: ubuntu-latest name: Stale issue job + permissions: + issues: write # to mark and close stale issues + pull-requests: write # to mark and close stale pull requests steps: - - uses: aws-actions/stale-issue-cleanup@v7 + - uses: aws-actions/stale-issue-cleanup@0604f2edf84a3a66bc0dfb4a30eb07814cbdf440 # v7.1.1 with: # Setting messages to an empty string will cause the automation to skip # that category diff --git a/.github/workflows/sync-master-dev.yml b/.github/workflows/sync-master-dev.yml index ae1f6e923..d3f91027f 100644 --- a/.github/workflows/sync-master-dev.yml +++ b/.github/workflows/sync-master-dev.yml @@ -8,9 +8,11 @@ on: pull_request: types: [closed] -permissions: - contents: write - id-token: write +permissions: {} + +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false jobs: # This job will check if the PR was successfully merged, it's source branch is `releases/next-release` and target branch is `dev`. @@ -23,6 +25,9 @@ jobs: github.event.pull_request.head.ref == 'releases/next-release' && github.event.pull_request.base.ref == 'dev' runs-on: ubuntu-latest + permissions: + contents: write # to merge dev into master and push the release tag + id-token: write # to assume AWS roles via OIDC steps: # Assume an AWS Role that provides access to the Access Token - name: Configure AWS Credentials @@ -96,8 +101,11 @@ jobs: - name: Create GitHub Release env: GITHUB_TOKEN: ${{ env.FG_PAT }} + TAG: ${{ steps.read-tag-name.outputs.TAG }} + VERSION: ${{ steps.read-release-name.outputs.VERSION }} + CHANGELOG: ${{ steps.read-changelog.outputs.CHANGELOG }} run: | - gh release create "${{ steps.read-tag-name.outputs.TAG }}" --title "${{ steps.read-release-name.outputs.VERSION }}" --notes "${{ steps.read-changelog.outputs.CHANGELOG }}" + gh release create "$TAG" --title "$VERSION" --notes "$CHANGELOG" # Delete the `releases/next-release` branch - name: Clean up run: | @@ -118,6 +126,8 @@ jobs: github.event.pull_request.head.ref == 'releases/next-release' && github.event.pull_request.base.ref == 'dev' runs-on: ubuntu-latest + permissions: + contents: write # to delete the release tag and branch steps: # Checkout a full clone of the repo using the deploy key (push runs over SSH) - name: Checkout code @@ -156,9 +166,11 @@ jobs: echo "TAG=$tag" >> $GITHUB_OUTPUT # Delete the tag created by AutoVer and the release branch - name: Clean up + env: + TAG: ${{ steps.read-tag-name.outputs.TAG }} run: | git fetch origin - git push --delete origin ${{ steps.read-tag-name.outputs.TAG }} + git push --delete origin "$TAG" if git ls-remote --exit-code --heads origin releases/next-release > /dev/null; then echo "Branch 'releases/next-release' exists on origin. Deleting..." git push origin --delete releases/next-release diff --git a/.github/workflows/update-Dockerfiles.yml b/.github/workflows/update-Dockerfiles.yml index 7709115fd..283e51f81 100644 --- a/.github/workflows/update-Dockerfiles.yml +++ b/.github/workflows/update-Dockerfiles.yml @@ -1,8 +1,6 @@ name: Update Lambda Dockerfiles -permissions: - contents: write - pull-requests: write +permissions: {} on: # Allows to run this workflow manually from the Actions tab @@ -65,9 +63,17 @@ on: type: string required: true +concurrency: + group: ${{ github.workflow }} + cancel-in-progress: false + jobs: build: + name: Update Dockerfiles and open PR runs-on: ubuntu-latest + permissions: + contents: write # to push the Dockerfile update branch + pull-requests: write # to open the update PR and label it env: NET_8_AMD64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/amd64/Dockerfile" NET_8_ARM64_Dockerfile: "LambdaRuntimeDockerfiles/Images/net8/arm64/Dockerfile" @@ -92,7 +98,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_8_AMD64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_8_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_8_AMD64 == 'true' }} - name: Update .NET 8 ARM64 @@ -102,7 +108,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_8_ARM64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_8_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_8_ARM64 == 'true' }} - name: Update .NET 9 AMD64 @@ -112,7 +118,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_9_AMD64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_9_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_9_AMD64 == 'true' }} - name: Update .NET 9 ARM64 @@ -122,7 +128,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_9_ARM64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_9_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_9_ARM64 == 'true' }} - name: Update .NET 10 AMD64 @@ -132,7 +138,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_10_AMD64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_10_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_10_AMD64 == 'true' }} - name: Update .NET 10 ARM64 @@ -142,7 +148,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_10_ARM64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_10_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_10_ARM64 == 'true' }} - name: Update .NET 11 AMD64 @@ -152,7 +158,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_11_AMD64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_11_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_11_AMD64 == 'true' }} - name: Update .NET 11 ARM64 @@ -162,7 +168,7 @@ jobs: DOCKERFILE_PATH: ${{ env.NET_11_ARM64_Dockerfile }} NEXT_VERSION: ${{ github.event.inputs.NET_11_NEXT_VERSION }} run: | - .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "${{ env.DOCKERFILE_PATH }}" -NextVersion "${{ env.NEXT_VERSION }}" + .\LambdaRuntimeDockerfiles\update-dockerfile.ps1 -DockerfilePath "$env:DOCKERFILE_PATH" -NextVersion "$env:NEXT_VERSION" if: ${{ github.event.inputs.NET_11_ARM64 == 'true' }} # Update Dockerfiles if newer version of ASP.NET Core is available @@ -184,7 +190,7 @@ jobs: - name: Pull Request id: pull-request if: ${{ steps.commit-push.outputs.BRANCH }} - uses: repo-sync/pull-request@v2 + uses: repo-sync/pull-request@7e79a9f5dc3ad0ce53138f01df2fad14a04831c5 # v2 with: source_branch: ${{ steps.commit-push.outputs.BRANCH }} destination_branch: "dev" @@ -211,13 +217,15 @@ jobs: # Add "Release Not Needed" label to the PR - name: Add Release Not Needed label if: ${{ steps.pull-request.outputs.pr_number }} - uses: actions/github-script@v8 + uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0 + env: + PR_NUMBER: ${{ steps.pull-request.outputs.pr_number }} with: github-token: ${{ secrets.GITHUB_TOKEN }} script: | github.rest.issues.addLabels({ owner: context.repo.owner, repo: context.repo.repo, - issue_number: ${{ steps.pull-request.outputs.pr_number }}, + issue_number: Number(process.env.PR_NUMBER), labels: ['Release Not Needed'] }) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab6d6a3b2..a95470214 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## Release 2026-06-26 + +### Amazon.Lambda.DurableExecution (0.1.2-preview) +* Add internal IDurableServiceClient abstraction over the durable execution service RPCs so the testing package can inject an in-memory implementation; route DurableFunction.WrapAsync overloads through it. + ## Release 2026-06-25 ### Amazon.Lambda.Annotations (2.1.0) diff --git a/Libraries/Libraries.sln b/Libraries/Libraries.sln index 1b2bedcd5..9f66a0031 100644 --- a/Libraries/Libraries.sln +++ b/Libraries/Libraries.sln @@ -165,6 +165,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecut EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AnnotationsClassLibraryFunction", "test\Amazon.Lambda.DurableExecution.IntegrationTests\TestFunctions\AnnotationsClassLibraryFunction\AnnotationsClassLibraryFunction.csproj", "{D55E2D57-8374-4573-999B-6E64E109C25F}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution.Analyzers", "src\Amazon.Lambda.DurableExecution.Analyzers\Amazon.Lambda.DurableExecution.Analyzers.csproj", "{6702F1BE-3A11-4DFB-93C3-50065789D814}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Amazon.Lambda.DurableExecution.Analyzers.Tests", "test\Amazon.Lambda.DurableExecution.Analyzers.Tests\Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj", "{592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1039,6 +1043,30 @@ Global {D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x64.Build.0 = Release|Any CPU {D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x86.ActiveCfg = Release|Any CPU {D55E2D57-8374-4573-999B-6E64E109C25F}.Release|x86.Build.0 = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x64.ActiveCfg = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x64.Build.0 = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x86.ActiveCfg = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Debug|x86.Build.0 = Debug|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|Any CPU.Build.0 = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x64.ActiveCfg = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x64.Build.0 = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x86.ActiveCfg = Release|Any CPU + {6702F1BE-3A11-4DFB-93C3-50065789D814}.Release|x86.Build.0 = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x64.ActiveCfg = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x64.Build.0 = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x86.ActiveCfg = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Debug|x86.Build.0 = Debug|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|Any CPU.Build.0 = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x64.ActiveCfg = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x64.Build.0 = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x86.ActiveCfg = Release|Any CPU + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1120,6 +1148,8 @@ Global {CA132CAB-FF4F-4312-B3A3-66DE9D360F27} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {16B1B1CC-3AFC-4DC7-8DB6-D14AE12924A2} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} {D55E2D57-8374-4573-999B-6E64E109C25F} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} + {6702F1BE-3A11-4DFB-93C3-50065789D814} = {AAB54E74-20B1-42ED-BC3D-CE9F7BC7FD12} + {592F5A22-B03A-4E0B-9DCD-093FBF29ACFC} = {1DE4EE60-45BA-4EF7-BE00-B9EB861E4C69} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {503678A4-B8D1-4486-8915-405A3E9CF0EB} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj new file mode 100644 index 000000000..f64018a6e --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/Amazon.Lambda.DurableExecution.Analyzers.csproj @@ -0,0 +1,48 @@ + + + + + netstandard2.0 + latest + enable + disable + + Roslyn analyzers and code fixes for Amazon.Lambda.DurableExecution — catches durable-execution determinism and authoring mistakes (DE001-DE004) at build time. + Amazon.Lambda.DurableExecution.Analyzers + Amazon.Lambda.DurableExecution.Analyzers + AWS;Amazon;Lambda;Durable;Workflow;Analyzer;Roslyn + + + false + false + true + + + ..\..\..\buildtools\public.snk + true + + true + true + + + $(NoWarn);RS1032;RS1033 + + + + + + + + + + + + + + + diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md new file mode 100644 index 000000000..f50bb1fe2 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Shipped.md @@ -0,0 +1,2 @@ +; Shipped analyzer releases +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md new file mode 100644 index 000000000..ceae365d0 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/AnalyzerReleases.Unshipped.md @@ -0,0 +1,11 @@ +; Unshipped analyzer release +; https://github.com/dotnet/roslyn-analyzers/blob/main/src/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md + +### New Rules + +Rule ID | Category | Severity | Notes +--------|----------|----------|---------------------------------------------------------------------- +DE001 | AWSLambdaDurableExecution | Warning | Non-deterministic call outside a step. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de001 +DE002 | AWSLambdaDurableExecution | Warning | Nested durable operation inside a step body. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de002 +DE003 | AWSLambdaDurableExecution | Warning | Mutable variable captured and modified inside a durable operation. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de003 +DE004 | AWSLambdaDurableExecution | Info | Task.WhenAll/WhenAny over durable tasks; prefer ParallelAsync/MapAsync. https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md#de004 diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs new file mode 100644 index 000000000..448fea326 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableDiagnostics.cs @@ -0,0 +1,82 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using Microsoft.CodeAnalysis; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// Diagnostic descriptors for the durable-execution analyzers. These mirror the + /// JavaScript SDK's ESLint plugin rules (no-non-deterministic-outside-step, + /// no-nested-durable-operations, no-closure-in-durable-operations) and add a + /// .NET-specific rule (DE004) for the Task.WhenAll/WhenAny pattern. + /// + public static class DurableDiagnostics + { + internal const string Category = "AWSLambdaDurableExecution"; + + private const string HelpRoot = + "https://github.com/aws/aws-lambda-dotnet/blob/master/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md"; + + /// + /// DE001 — a non-deterministic API (DateTime.Now, Guid.NewGuid, Random, …) is used in + /// workflow code outside a step. On replay the workflow re-runs from the top, so the value + /// differs between the original run and replays, corrupting checkpoint-derived state. + /// + public static readonly DiagnosticDescriptor NonDeterministicCallOutsideStep = new DiagnosticDescriptor( + id: "DE001", + title: "Non-deterministic call outside a step", + messageFormat: "Non-deterministic operation '{0}' is used in workflow code outside a step. Move it inside a step (e.g. context.StepAsync(...)) so its result is checkpointed and replays consistently", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Durable workflow code re-executes from the top on every invocation. Values from non-deterministic APIs differ between the original execution and replays unless they are captured inside a step.", + helpLinkUri: HelpRoot + "#de001"); + + /// + /// DE002 — a durable operation is invoked inside a step body by capturing the outer + /// IDurableContext. Step bodies must contain only plain, deterministic code; + /// nesting durable operations requires RunInChildContextAsync. + /// + public static readonly DiagnosticDescriptor NestedDurableOperationInsideStep = new DiagnosticDescriptor( + id: "DE002", + title: "Nested durable operation inside a step body", + messageFormat: "Durable operation '{0}' is called on the outer durable context '{1}' inside a {2} body. Step bodies must contain only plain, deterministic code; nest durable operations with context.RunInChildContextAsync(...) instead", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "A step body is replayed verbatim on every retry. Calling another durable operation from inside it produces unpredictable behavior; use RunInChildContextAsync to group durable operations.", + helpLinkUri: HelpRoot + "#de002"); + + /// + /// DE003 — a variable captured from an outer scope is mutated inside a durable-operation + /// delegate. On replay the operation returns its cached result without re-executing the body, + /// so the write never happens and the captured variable holds stale state. + /// + public static readonly DiagnosticDescriptor MutableCaptureInDurableOperation = new DiagnosticDescriptor( + id: "DE003", + title: "Mutable variable captured and modified inside a durable operation", + messageFormat: "Variable '{0}' is captured from an outer scope and modified inside a durable operation. On replay the operation returns its cached result without re-executing the body, so this write is lost and '{0}' becomes stale. Return the value from the operation and assign it (e.g. {0} = await context.StepAsync(...)) instead", + category: Category, + defaultSeverity: DiagnosticSeverity.Warning, + isEnabledByDefault: true, + description: "Reading a captured variable inside a durable operation is safe; mutating it is not, because the body is skipped on replay.", + helpLinkUri: HelpRoot + "#de003"); + + /// + /// DE004 — Task.WhenAll/Task.WhenAny is called with tasks produced by durable + /// operations. This is not incorrect (operation IDs are allocated deterministically), but it + /// bypasses completion policies, concurrency limits, branch naming, and IBatchResult output. + /// Advisory (Info) only. + /// + public static readonly DiagnosticDescriptor DurableTaskInTaskCombinator = new DiagnosticDescriptor( + id: "DE004", + title: "Prefer ParallelAsync/MapAsync over Task.WhenAll/WhenAny for durable tasks", + messageFormat: "'{0}' over durable tasks bypasses completion policies, concurrency limits, branch naming, and IBatchResult output. Use context.ParallelAsync (or MapAsync) so concurrent durable operations get framework coordination and complete execution traces", + category: Category, + defaultSeverity: DiagnosticSeverity.Info, + isEnabledByDefault: true, + description: "Task.WhenAll/WhenAny work correctly with durable tasks, but ParallelAsync/MapAsync are preferred for completion policies, concurrency control, and observability.", + helpLinkUri: HelpRoot + "#de004"); + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs new file mode 100644 index 000000000..40b05ec51 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableKnownSymbols.cs @@ -0,0 +1,344 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// How a delegate passed to a durable operation executes, which determines what the + /// analyzers allow inside its body. + /// + internal enum DurableDelegateRole + { + /// Not a delegate accepted by a durable operation. + None, + + /// + /// The delegate body runs inside a checkpointed step (StepAsync func, + /// WaitForCallbackAsync submitter, WaitForConditionAsync check). Non-deterministic code is + /// allowed here because the result is checkpointed; nested durable operations are not. + /// + StepWrapped, + + /// + /// The delegate body runs as a child sub-workflow (RunInChildContextAsync func, ParallelAsync + /// branch, MapAsync func). It is still workflow code (must be deterministic), but nested + /// durable operations are allowed. + /// + ChildContext, + } + + /// + /// Per-compilation cache of the durable-execution and BCL symbols the analyzers match against. + /// Resolved once in a compilation-start action; if IDurableContext is not present + /// the compilation does not use durable execution and the analyzers register nothing. + /// + internal sealed class DurableKnownSymbols + { + internal const string IDurableContextMetadataName = "Amazon.Lambda.DurableExecution.IDurableContext"; + + /// The names of the durable operations declared on IDurableContext. + private static readonly ImmutableHashSet DurableOperationNames = ImmutableHashSet.Create( + "StepAsync", + "WaitAsync", + "RunInChildContextAsync", + "CreateCallbackAsync", + "WaitForCallbackAsync", + "InvokeAsync", + "WaitForConditionAsync", + "ParallelAsync", + "MapAsync"); + + internal INamedTypeSymbol DurableContext { get; } + + // BCL types backing the DE001 non-determinism catalog. Any may be null on a given target framework. + private readonly INamedTypeSymbol? _dateTime; + private readonly INamedTypeSymbol? _dateTimeOffset; + private readonly INamedTypeSymbol? _guid; + private readonly INamedTypeSymbol? _random; + private readonly INamedTypeSymbol? _stopwatch; + private readonly INamedTypeSymbol? _environment; + private readonly INamedTypeSymbol? _path; + private readonly INamedTypeSymbol? _randomNumberGenerator; + private readonly INamedTypeSymbol? _rngCryptoServiceProvider; + private readonly INamedTypeSymbol? _task; + private readonly INamedTypeSymbol? _taskOfT; + + private DurableKnownSymbols(Compilation compilation, INamedTypeSymbol durableContext) + { + DurableContext = durableContext; + _dateTime = compilation.GetTypeByMetadataName("System.DateTime"); + _dateTimeOffset = compilation.GetTypeByMetadataName("System.DateTimeOffset"); + _guid = compilation.GetTypeByMetadataName("System.Guid"); + _random = compilation.GetTypeByMetadataName("System.Random"); + _stopwatch = compilation.GetTypeByMetadataName("System.Diagnostics.Stopwatch"); + _environment = compilation.GetTypeByMetadataName("System.Environment"); + _path = compilation.GetTypeByMetadataName("System.IO.Path"); + _randomNumberGenerator = compilation.GetTypeByMetadataName("System.Security.Cryptography.RandomNumberGenerator"); + _rngCryptoServiceProvider = compilation.GetTypeByMetadataName("System.Security.Cryptography.RNGCryptoServiceProvider"); + _task = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task"); + _taskOfT = compilation.GetTypeByMetadataName("System.Threading.Tasks.Task`1"); + } + + /// + /// Resolves the durable symbols. Returns null when the compilation does not reference + /// Amazon.Lambda.DurableExecution, so callers register no per-node work. + /// + internal static DurableKnownSymbols? TryCreate(Compilation compilation) + { + var durableContext = compilation.GetTypeByMetadataName(IDurableContextMetadataName); + return durableContext is null ? null : new DurableKnownSymbols(compilation, durableContext); + } + + /// + /// True if is IDurableContext or implements it (covers the + /// concrete DurableContext and any user implementation). + /// + internal bool IsDurableContextType(ITypeSymbol? type) + { + if (type is null) + { + return false; + } + + if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, DurableContext)) + { + return true; + } + + foreach (var iface in type.AllInterfaces) + { + if (SymbolEqualityComparer.Default.Equals(iface.OriginalDefinition, DurableContext)) + { + return true; + } + } + + return false; + } + + /// + /// True if is one of the durable operations on a durable context. + /// Matched by symbol (containing type is/implements IDurableContext) plus an explicit + /// name allowlist, so property getters and ConfigureLogger are excluded, and an + /// unrelated obj.StepAsync() on some other type is never matched. + /// + internal bool IsDurableOperation(IMethodSymbol? method, out string operationName, out DurableDelegateRole role) + { + operationName = string.Empty; + role = DurableDelegateRole.None; + + if (method is null) + { + return false; + } + + var name = method.OriginalDefinition.Name; + if (!DurableOperationNames.Contains(name)) + { + return false; + } + + if (!IsDurableContextType(method.OriginalDefinition.ContainingType)) + { + return false; + } + + operationName = name; + role = RoleFor(name); + return true; + } + + private static DurableDelegateRole RoleFor(string operationName) + { + switch (operationName) + { + case "StepAsync": + case "WaitForCallbackAsync": + case "WaitForConditionAsync": + return DurableDelegateRole.StepWrapped; + case "RunInChildContextAsync": + case "ParallelAsync": + case "MapAsync": + return DurableDelegateRole.ChildContext; + default: + return DurableDelegateRole.None; + } + } + + /// True if is Task.WhenAll or Task.WhenAny. + internal bool IsTaskCombinator(IMethodSymbol? method, out string friendlyName) + { + friendlyName = string.Empty; + if (method is null || _task is null) + { + return false; + } + + var def = method.OriginalDefinition; + if (!SymbolEqualityComparer.Default.Equals(def.ContainingType, _task)) + { + return false; + } + + if (def.Name == "WhenAll") + { + friendlyName = "Task.WhenAll"; + return true; + } + + if (def.Name == "WhenAny") + { + friendlyName = "Task.WhenAny"; + return true; + } + + return false; + } + + /// True if the type is Task or Task<T> (a candidate durable task). + internal bool IsTaskType(ITypeSymbol? type) + { + if (type is null) + { + return false; + } + + var def = type.OriginalDefinition; + return SymbolEqualityComparer.Default.Equals(def, _task) + || SymbolEqualityComparer.Default.Equals(def, _taskOfT); + } + + /// + /// Returns the friendly name (e.g. "DateTime.Now") if the operation reads/calls/creates + /// a non-deterministic API from the DE001 catalog; otherwise null. + /// + internal string? TryGetNonDeterministicApi(IOperation operation) + { + switch (operation) + { + case IPropertyReferenceOperation pr: + return MatchProperty(pr.Property); + case IInvocationOperation inv: + return MatchMethod(inv.TargetMethod); + case IObjectCreationOperation oc: + return MatchObjectCreation(oc); + default: + return null; + } + } + + private string? MatchProperty(IPropertySymbol property) + { + var owner = property.ContainingType?.OriginalDefinition; + var name = property.Name; + + if (SymbolEqualityComparer.Default.Equals(owner, _dateTime) + && (name == "Now" || name == "UtcNow" || name == "Today")) + { + return "DateTime." + name; + } + + if (SymbolEqualityComparer.Default.Equals(owner, _dateTimeOffset) + && (name == "Now" || name == "UtcNow")) + { + return "DateTimeOffset." + name; + } + + if (SymbolEqualityComparer.Default.Equals(owner, _environment) + && (name == "TickCount" || name == "TickCount64")) + { + return "Environment." + name; + } + + if (SymbolEqualityComparer.Default.Equals(owner, _stopwatch) + && (name == "Elapsed" || name == "ElapsedMilliseconds" || name == "ElapsedTicks")) + { + return "Stopwatch." + name; + } + + // Random.Shared is seeded non-deterministically. (A user-seeded `new Random(42)` is + // deterministic, so we flag the seedless ctor / Random.Shared, not the .Next() call.) + if (SymbolEqualityComparer.Default.Equals(owner, _random) && name == "Shared") + { + return "Random.Shared"; + } + + return null; + } + + private string? MatchMethod(IMethodSymbol method) + { + var owner = method.ContainingType?.OriginalDefinition; + var name = method.Name; + + if (SymbolEqualityComparer.Default.Equals(owner, _guid) && name == "NewGuid") + { + return "Guid.NewGuid()"; + } + + // Note: Random instance methods (.Next() etc.) are intentionally NOT flagged here — a + // user-seeded `new Random(42)` is deterministic. Non-determinism is introduced by the + // seedless `new Random()` ctor (MatchObjectCreation) and `Random.Shared` (MatchProperty). + if (SymbolEqualityComparer.Default.Equals(owner, _stopwatch) + && (name == "GetTimestamp" || name == "StartNew")) + { + return "Stopwatch." + name + "()"; + } + + if (SymbolEqualityComparer.Default.Equals(owner, _randomNumberGenerator) + && (name == "GetBytes" || name == "GetInt32" || name == "Fill" + || name == "GetString" || name == "GetHexString")) + { + return "RandomNumberGenerator." + name + "()"; + } + + if (SymbolEqualityComparer.Default.Equals(owner, _path) + && (name == "GetTempFileName" || name == "GetRandomFileName")) + { + return "Path." + name + "()"; + } + + return null; + } + + private string? MatchObjectCreation(IObjectCreationOperation oc) + { + var type = oc.Constructor?.ContainingType?.OriginalDefinition; + + // Only the seedless Random ctor is non-deterministic; new Random(seed) is fine. + if (SymbolEqualityComparer.Default.Equals(type, _random) && oc.Arguments.Length == 0) + { + return "new Random()"; + } + + if (SymbolEqualityComparer.Default.Equals(type, _rngCryptoServiceProvider)) + { + return "new RNGCryptoServiceProvider()"; + } + + return null; + } + + /// + /// True if contains an IDurableContext-typed parameter, + /// which marks a method/local-function/lambda as durable workflow code. + /// + internal bool HasDurableContextParameter(IEnumerable parameters) + { + foreach (var p in parameters) + { + if (IsDurableContextType(p.Type)) + { + return true; + } + } + + return false; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs new file mode 100644 index 000000000..5ec1846df --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableScope.cs @@ -0,0 +1,201 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// Shared helpers for reasoning about where an operation sits relative to durable-operation + /// delegates. Used by DE001 (is this non-deterministic call inside a step?) and DE002 (is this + /// durable call inside a step body?). + /// + internal static class DurableScope + { + /// + /// Describes the nearest enclosing durable-operation delegate of an operation, if any. + /// + internal readonly struct EnclosingDelegate + { + internal EnclosingDelegate(DurableDelegateRole role, string operationName, IAnonymousFunctionOperation function) + { + Role = role; + OperationName = operationName; + Function = function; + } + + internal DurableDelegateRole Role { get; } + + internal string OperationName { get; } + + internal IAnonymousFunctionOperation Function { get; } + + internal bool Found => Role != DurableDelegateRole.None; + } + + /// + /// Walks up the operation tree from and returns the nearest + /// enclosing lambda that is an argument to a durable operation, classified by its role. + /// Non-durable lambdas (e.g. a Select projection or Task.Run) are skipped so a + /// non-deterministic call nested in such a lambda is still attributed to the durable step that + /// contains it. + /// + internal static EnclosingDelegate FindNearestDurableDelegate(IOperation operation, DurableKnownSymbols symbols) + { + for (var current = operation.Parent; current is not null; current = current.Parent) + { + if (current is IAnonymousFunctionOperation lambda + && TryClassifyDelegate(lambda, symbols, out var role, out var opName)) + { + return new EnclosingDelegate(role, opName, lambda); + } + } + + return default; + } + + /// + /// True if any enclosing scope (method, local function, or lambda) of + /// is durable workflow code — i.e. declares an + /// IDurableContext parameter. This is the single shared scoping primitive: it covers + /// the [DurableExecution] annotation path, the hand-wired + /// DurableFunction.WrapAsync(async (input, ctx) => …) lambda, and child/parallel/map + /// branch delegates. + /// + internal static bool IsInWorkflowCode(IOperation operation, DurableKnownSymbols symbols) + { + for (var current = operation.Parent; current is not null; current = current.Parent) + { + switch (current) + { + case IAnonymousFunctionOperation lambda + when symbols.HasDurableContextParameter(lambda.Symbol.Parameters): + return true; + case ILocalFunctionOperation local + when symbols.HasDurableContextParameter(local.Symbol.Parameters): + return true; + case IMethodBodyOperation: + case IBlockOperation { Parent: null }: + break; + } + } + + // The enclosing method symbol (top-level body, not reachable as an IOperation parent). + var enclosing = operation.SemanticModel?.GetEnclosingSymbol(operation.Syntax.SpanStart) as IMethodSymbol; + while (enclosing is not null) + { + if (symbols.HasDurableContextParameter(enclosing.Parameters)) + { + return true; + } + + enclosing = enclosing.ContainingSymbol as IMethodSymbol; + } + + return false; + } + + /// + /// If is passed as an argument to a durable operation, reports which + /// operation and the delegate's role. Maps the lambda's argument position to the corresponding + /// parameter so the WaitForCallbackAsync submitter / WaitForConditionAsync check (which are + /// step-wrapped) and the ParallelAsync/MapAsync branches (child context) are classified + /// correctly — even though they are not the operation's "first" delegate. + /// + internal static bool TryClassifyDelegate( + IAnonymousFunctionOperation lambda, + DurableKnownSymbols symbols, + out DurableDelegateRole role, + out string operationName) + { + role = DurableDelegateRole.None; + operationName = string.Empty; + + // The lambda may be a direct argument (StepAsync(lambda)), wrapped in a delegate + // conversion, or nested inside the array/collection that builds the branches list of + // ParallelAsync ([ (c, ct) => …, (c, ct) => … ]). Walk up through those expression + // wrappers to the enclosing argument, but stop at any statement, block, or another + // anonymous function so we never attribute the lambda to a non-enclosing invocation. + // This avoids naming ICollectionExpressionOperation, which is not in all Roslyn versions. + IOperation? argument = lambda.Parent; + while (argument is not null + && argument is not IArgumentOperation + && argument is not IAnonymousFunctionOperation + && argument is not IBlockOperation + && argument is not IExpressionStatementOperation) + { + argument = argument.Parent; + } + + if (argument is not IArgumentOperation arg || arg.Parent is not IInvocationOperation invocation) + { + return false; + } + + if (!symbols.IsDurableOperation(invocation.TargetMethod, out operationName, out role)) + { + role = DurableDelegateRole.None; + operationName = string.Empty; + return false; + } + + return role != DurableDelegateRole.None; + } + + /// + /// Collects the symbols (parameters and locals) declared within , + /// including nested lambdas/local functions, so DE003 can tell a captured outer variable from a + /// delegate-local one. + /// + internal static HashSet CollectDeclaredSymbols(IAnonymousFunctionOperation function) + { + // The comparer IS supplied; this suppresses a known RS1024 false positive in the 4.0.1 + // analyzer pack that flags `new HashSet(SymbolEqualityComparer.Default)`. +#pragma warning disable RS1024 // Compare symbols correctly + var declared = new HashSet(SymbolEqualityComparer.Default); +#pragma warning restore RS1024 + + foreach (var p in function.Symbol.Parameters) + { + declared.Add(p); + } + + CollectDescendantDeclarations(function.Body, declared); + return declared; + } + + private static void CollectDescendantDeclarations(IOperation? operation, HashSet declared) + { + if (operation is null) + { + return; + } + + foreach (var child in operation.Descendants()) + { + switch (child) + { + case IVariableDeclaratorOperation decl: + declared.Add(decl.Symbol); + break; + case IAnonymousFunctionOperation nested: + foreach (var p in nested.Symbol.Parameters) + { + declared.Add(p); + } + + break; + case ILocalFunctionOperation localFn: + foreach (var p in localFn.Symbol.Parameters) + { + declared.Add(p); + } + + break; + } + } + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs new file mode 100644 index 000000000..1b232c4b9 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorAnalyzer.cs @@ -0,0 +1,200 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// DE004 — flags Task.WhenAll/Task.WhenAny called with tasks produced by durable + /// operations. This is advisory (Info): the combinators work correctly with durable tasks because + /// operation IDs are allocated deterministically, but they bypass completion policies, concurrency + /// limits, branch naming, and structured IBatchResult output, so ParallelAsync / + /// MapAsync are preferred. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class DurableTaskCombinatorAnalyzer : DiagnosticAnalyzer + { + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DurableDiagnostics.DurableTaskInTaskCombinator); + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStart => + { + var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation); + if (symbols is null) + { + return; + } + + compilationStart.RegisterOperationAction( + ctx => AnalyzeInvocation(ctx, symbols), + OperationKind.Invocation); + }); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols) + { + var invocation = (IInvocationOperation)context.Operation; + + if (!symbols.IsTaskCombinator(invocation.TargetMethod, out var friendlyName)) + { + return; + } + + // Gather the task expressions passed in and report if any is produced by a durable op. + foreach (var argument in invocation.Arguments) + { + if (ArgumentContainsDurableTask(argument.Value, symbols)) + { + context.ReportDiagnostic(Diagnostic.Create( + DurableDiagnostics.DurableTaskInTaskCombinator, + invocation.Syntax.GetLocation(), + friendlyName)); + return; + } + } + } + + /// + /// True if the argument value contains (directly, or via the local it references) at least one + /// task produced by a durable-context operation. + /// + private static bool ArgumentContainsDurableTask(IOperation value, DurableKnownSymbols symbols) + { + // Strip params-array / collection conversions. + value = Unwrap(value); + + // Inline: Task.WhenAll(ctx.StepAsync(a), ctx.StepAsync(b)) or new[] { ... } or [ ... ]. + foreach (var op in value.DescendantsAndSelf()) + { + if (op is IInvocationOperation inv && IsDurableTaskProducer(inv, symbols)) + { + return true; + } + } + + // Indirect: Task[] tasks = ...; await Task.WhenAll(tasks). Follow a local to its + // initializer and to assignments/Add calls in the enclosing method body. + if (value is ILocalReferenceOperation localRef) + { + return LocalIsPopulatedWithDurableTasks(localRef, symbols); + } + + return false; + } + + private static IOperation Unwrap(IOperation op) + { + while (op is IConversionOperation conv) + { + op = conv.Operand; + } + + return op; + } + + private static bool IsDurableTaskProducer(IInvocationOperation invocation, DurableKnownSymbols symbols) + { + if (!symbols.IsDurableOperation(invocation.TargetMethod, out _, out _)) + { + return false; + } + + return invocation.Instance is not null && symbols.IsDurableContextType(invocation.Instance.Type); + } + + /// + /// Bounded scan: walks the enclosing method/lambda body for the declaration of the referenced + /// local and any assignments or List.Add calls that store a durable task into it. + /// + private static bool LocalIsPopulatedWithDurableTasks(ILocalReferenceOperation localRef, DurableKnownSymbols symbols) + { + var local = localRef.Local; + + // Find the enclosing body operation that contains the WhenAll call. + IOperation root = localRef; + while (root.Parent is not null) + { + root = root.Parent; + } + + foreach (var op in root.DescendantsAndSelf()) + { + switch (op) + { + case IVariableDeclaratorOperation decl + when SymbolEqualityComparer.Default.Equals(decl.Symbol, local) + && decl.Initializer is not null: + if (ContainsDurableTaskProducer(decl.Initializer.Value, symbols)) + { + return true; + } + + break; + + case ISimpleAssignmentOperation assign + when TargetsLocal(assign.Target, local): + if (ContainsDurableTaskProducer(assign.Value, symbols)) + { + return true; + } + + break; + + case IInvocationOperation addCall + when addCall.TargetMethod.Name == "Add" + && addCall.Instance is ILocalReferenceOperation listRef + && SymbolEqualityComparer.Default.Equals(listRef.Local, local): + foreach (var addArg in addCall.Arguments) + { + if (ContainsDurableTaskProducer(addArg.Value, symbols)) + { + return true; + } + } + + break; + } + } + + return false; + } + + private static bool TargetsLocal(IOperation target, ILocalSymbol local) + { + // Direct local assignment, or element assignment tasks[i] = ... + switch (Unwrap(target)) + { + case ILocalReferenceOperation l: + return SymbolEqualityComparer.Default.Equals(l.Local, local); + case IArrayElementReferenceOperation arr when arr.ArrayReference is ILocalReferenceOperation arrLocal: + return SymbolEqualityComparer.Default.Equals(arrLocal.Local, local); + default: + return false; + } + } + + private static bool ContainsDurableTaskProducer(IOperation value, DurableKnownSymbols symbols) + { + foreach (var op in Unwrap(value).DescendantsAndSelf()) + { + if (op is IInvocationOperation inv && IsDurableTaskProducer(inv, symbols)) + { + return true; + } + } + + return false; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs new file mode 100644 index 000000000..6bd52acce --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/DurableTaskCombinatorCodeFixProvider.cs @@ -0,0 +1,233 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// Code fix for DE004: rewrites await Task.WhenAll(ctx.StepAsync(a), ctx.StepAsync(b)) into + /// await ctx.ParallelAsync(new[] { (c, ct) => c.StepAsync(a), (c, ct) => c.StepAsync(b) }) + /// for the one provably-safe shape: an inline list of direct durable calls on a single shared + /// context whose aggregate result is discarded. Every other shape (result consumed, mixed task + /// types, a variable instead of an inline list, WhenAny) is left diagnostic-only. + /// + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(DurableTaskCombinatorCodeFixProvider))] + [Shared] + public sealed class DurableTaskCombinatorCodeFixProvider : CodeFixProvider + { + /// + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(DurableDiagnostics.DurableTaskInTaskCombinator.Id); + + /// Fix-all is disabled — each conversion changes durable-operation structure and is reviewed individually. + public override FixAllProvider? GetFixAllProvider() => null; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + var invocation = node.FirstAncestorOrSelf(); + if (invocation is null) + { + return; + } + + // Only WhenAll (not WhenAny) and only when its result is discarded (the await is an + // expression statement, not consumed by an assignment / argument / return). + if (GetCombinatorName(invocation) != "WhenAll" || !ResultIsDiscarded(invocation)) + { + return; + } + + // All arguments must be inline durable calls of the SAME receiver identifier. + if (!TryGetHomogeneousDurableCalls(invocation, out var receiver, out var calls)) + { + return; + } + + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return; + } + + // Determine the shared result type T (each durable call returns Task). Bail if the calls + // are not all the same T — that is not the provably-safe homogeneous shape. + var elementType = TryGetSharedResultType(calls, semanticModel, context.CancellationToken); + if (elementType is null) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Convert to {receiver}.ParallelAsync(...)", + createChangedDocument: ct => ConvertAsync(context.Document, root, invocation, receiver, calls, elementType, ct), + equivalenceKey: "ConvertToParallelAsync"), + diagnostic); + } + + private static string? TryGetSharedResultType( + List calls, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + string? shared = null; + foreach (var call in calls) + { + if (semanticModel.GetTypeInfo(call, cancellationToken).Type is not INamedTypeSymbol taskType + || taskType.TypeArguments.Length != 1) + { + return null; // Non-generic Task (void step) — not handled by this fix. + } + + var display = taskType.TypeArguments[0].ToDisplayString(); + if (shared is null) + { + shared = display; + } + else if (shared != display) + { + return null; // Heterogeneous result types — bail. + } + } + + return shared; + } + + private static string? GetCombinatorName(InvocationExpressionSyntax invocation) + { + return invocation.Expression switch + { + MemberAccessExpressionSyntax ma => ma.Name.Identifier.ValueText, + _ => null, + }; + } + + private static bool ResultIsDiscarded(InvocationExpressionSyntax invocation) + { + // The WhenAll invocation is typically wrapped in an await; the await must stand alone. + SyntaxNode current = invocation; + if (current.Parent is AwaitExpressionSyntax awaitExpr) + { + current = awaitExpr; + } + + return current.Parent is ExpressionStatementSyntax; + } + + private static bool TryGetHomogeneousDurableCalls( + InvocationExpressionSyntax invocation, + out string receiver, + out List calls) + { + receiver = string.Empty; + calls = new List(); + + var arguments = invocation.ArgumentList.Arguments; + if (arguments.Count < 2) + { + return false; // Nothing to parallelize. + } + + string? sharedReceiver = null; + foreach (var argument in arguments) + { + if (argument.Expression is not InvocationExpressionSyntax call + || call.Expression is not MemberAccessExpressionSyntax memberAccess + || memberAccess.Expression is not IdentifierNameSyntax receiverId) + { + return false; // Not a direct ctx.Method(...) call. + } + + if (sharedReceiver is null) + { + sharedReceiver = receiverId.Identifier.ValueText; + } + else if (sharedReceiver != receiverId.Identifier.ValueText) + { + return false; // Mixed receivers — bail. + } + + calls.Add(call); + } + + receiver = sharedReceiver!; + return true; + } + + private static Task ConvertAsync( + Document document, + SyntaxNode root, + InvocationExpressionSyntax whenAll, + string receiver, + List calls, + string elementType, + CancellationToken cancellationToken) + { + // Build one branch lambda per call: (c, ct) => c.StepAsync(...originalArgs...) + var branchLambdas = calls.Select(call => + { + var memberAccess = (MemberAccessExpressionSyntax)call.Expression; + var rebound = call.WithExpression( + memberAccess.WithExpression(SyntaxFactory.IdentifierName("c"))); + + return (ExpressionSyntax)SyntaxFactory.ParenthesizedLambdaExpression( + SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(new[] + { + SyntaxFactory.Parameter(SyntaxFactory.Identifier("c")), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("ct")), + })), + rebound); + }).ToArray(); + + // Explicitly-typed array — a lambda has no inferable type, so `new[] { … }` would not + // compile; emit `new Func>[] { … }`. + var arrayType = SyntaxFactory.ArrayType( + SyntaxFactory.ParseTypeName( + $"System.Func>"), + SyntaxFactory.SingletonList( + SyntaxFactory.ArrayRankSpecifier( + SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.OmittedArraySizeExpression())))); + + var arrayLiteral = SyntaxFactory.ArrayCreationExpression( + arrayType, + SyntaxFactory.InitializerExpression( + SyntaxKind.ArrayInitializerExpression, + SyntaxFactory.SeparatedList(branchLambdas))); + + var parallelCall = SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName(receiver), + SyntaxFactory.IdentifierName("ParallelAsync")), + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(arrayLiteral)))) + .WithTriviaFrom(whenAll) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(whenAll, parallelCall); + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs new file mode 100644 index 000000000..b2ba45da0 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/MutableCaptureAnalyzer.cs @@ -0,0 +1,157 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Generic; +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// DE003 — flags mutation of a variable captured from an outer scope inside a durable-operation + /// delegate (StepAsync, RunInChildContextAsync, WaitForConditionAsync, WaitForCallbackAsync, and + /// the ParallelAsync / MapAsync branches). On replay the delegate body is skipped and the cached + /// result returned, so the write never happens and the captured variable holds stale state. + /// Reading a captured variable is safe and not flagged. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class MutableCaptureAnalyzer : DiagnosticAnalyzer + { + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DurableDiagnostics.MutableCaptureInDurableOperation); + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStart => + { + var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation); + if (symbols is null) + { + return; + } + + compilationStart.RegisterOperationAction( + ctx => AnalyzeInvocation(ctx, symbols), + OperationKind.Invocation); + }); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols) + { + var invocation = (IInvocationOperation)context.Operation; + + if (!symbols.IsDurableOperation(invocation.TargetMethod, out _, out _)) + { + return; + } + + if (invocation.Instance is null || !symbols.IsDurableContextType(invocation.Instance.Type)) + { + return; + } + + // Each delegate argument is analyzed independently. ParallelAsync takes a list of branch + // delegates, so a single invocation can carry several lambdas (nested in array/collection + // wrappers). We collect only the OUTERMOST lambdas (the actual step/branch delegates); + // a lambda nested inside a delegate body is covered by that delegate's recursive analysis, + // so collecting it again would double-report the same captured write. + foreach (var argument in invocation.Arguments) + { + foreach (var lambda in OutermostLambdas(argument)) + { + AnalyzeDelegate(context, lambda); + } + } + } + + private static IEnumerable OutermostLambdas(IOperation root) + { + foreach (var op in root.Descendants()) + { + // Keep a lambda only if no other lambda sits between it and the argument root, so we + // return the branch/step delegates themselves, not lambdas nested inside their bodies. + if (op is IAnonymousFunctionOperation lambda && !HasEnclosingLambdaBelow(lambda, root)) + { + yield return lambda; + } + } + } + + private static bool HasEnclosingLambdaBelow(IOperation operation, IOperation root) + { + for (var parent = operation.Parent; parent is not null && !ReferenceEquals(parent, root); parent = parent.Parent) + { + if (parent is IAnonymousFunctionOperation) + { + return true; + } + } + + return false; + } + + private static void AnalyzeDelegate(OperationAnalysisContext context, IAnonymousFunctionOperation lambda) + { + var declaredInDelegate = DurableScope.CollectDeclaredSymbols(lambda); + + foreach (var descendant in lambda.Body.Descendants()) + { + var targetOp = GetAssignmentTargetOperation(descendant); + if (targetOp is null) + { + continue; + } + + var target = GetReferencedSymbol(targetOp); + if (target is null) + { + continue; + } + + // Captured iff the mutated symbol is not declared within this delegate (or a lambda + // nested inside it). Reads never reach here because only assignment targets are + // collected, so reading a captured variable is inherently allowed. + if (!declaredInDelegate.Contains(target)) + { + context.ReportDiagnostic(Diagnostic.Create( + DurableDiagnostics.MutableCaptureInDurableOperation, + targetOp.Syntax.GetLocation(), + target.Name)); + } + } + } + + /// + /// Returns the target operation being mutated by an assignment, compound assignment, coalesce + /// assignment, or increment/decrement; otherwise null. + /// + private static IOperation? GetAssignmentTargetOperation(IOperation operation) + { + return operation switch + { + ISimpleAssignmentOperation simple => simple.Target, + ICompoundAssignmentOperation compound => compound.Target, + ICoalesceAssignmentOperation coalesce => coalesce.Target, + IIncrementOrDecrementOperation incDec => incDec.Target, + _ => null, + }; + } + + private static ISymbol? GetReferencedSymbol(IOperation targetOp) + { + return targetOp switch + { + ILocalReferenceOperation local => local.Local, + IParameterReferenceOperation parameter => parameter.Parameter, + _ => null, + }; + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs new file mode 100644 index 000000000..f32184181 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NestedDurableOperationAnalyzer.cs @@ -0,0 +1,124 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// DE002 — flags a durable operation invoked inside a step-wrapped delegate (a StepAsync body, a + /// WaitForCallback submitter, or a WaitForCondition check) by capturing the outer durable context. + /// In .NET a step delegate receives IStepContext, which exposes no durable operations, so + /// the only way to compile a nested durable call is to capture the outer IDurableContext — + /// which is exactly what this rule detects. Nesting durable operations requires + /// RunInChildContextAsync. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class NestedDurableOperationAnalyzer : DiagnosticAnalyzer + { + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DurableDiagnostics.NestedDurableOperationInsideStep); + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStart => + { + var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation); + if (symbols is null) + { + return; + } + + compilationStart.RegisterOperationAction( + ctx => AnalyzeInvocation(ctx, symbols), + OperationKind.Invocation); + }); + } + + private static void AnalyzeInvocation(OperationAnalysisContext context, DurableKnownSymbols symbols) + { + var invocation = (IInvocationOperation)context.Operation; + + // Is this call itself a durable operation? + if (!symbols.IsDurableOperation(invocation.TargetMethod, out var nestedOpName, out _)) + { + return; + } + + // The receiver must be a durable context. Static/extension calls (Instance == null) and + // non-durable receivers are ignored. + if (invocation.Instance is null || !symbols.IsDurableContextType(invocation.Instance.Type)) + { + return; + } + + // Find the nearest enclosing durable delegate. We only care about step-wrapped bodies; + // nesting inside a child-context / parallel / map branch is legitimate. + var enclosing = DurableScope.FindNearestDurableDelegate(invocation, symbols); + if (!enclosing.Found || enclosing.Role != DurableDelegateRole.StepWrapped) + { + return; + } + + // The receiver must be captured from OUTSIDE this step delegate. A step delegate's own + // parameter is IStepContext (not a durable context), so any durable-context receiver here + // is necessarily captured — but we verify the symbol is not declared inside the delegate + // to stay correct for child/parallel branches that legitimately receive their own context. + var receiverSymbol = GetReferencedSymbol(invocation.Instance); + if (receiverSymbol is not null) + { + var declaredInDelegate = DurableScope.CollectDeclaredSymbols(enclosing.Function); + if (declaredInDelegate.Contains(receiverSymbol)) + { + return; // Receiver is local to the step delegate — not a captured outer context. + } + } + + var contextName = receiverSymbol?.Name ?? "context"; + var bodyKind = DescribeBody(enclosing.OperationName); + + context.ReportDiagnostic(Diagnostic.Create( + DurableDiagnostics.NestedDurableOperationInsideStep, + invocation.Syntax.GetLocation(), + nestedOpName, + contextName, + bodyKind)); + } + + private static ISymbol? GetReferencedSymbol(IOperation receiver) + { + switch (receiver) + { + case ILocalReferenceOperation local: + return local.Local; + case IParameterReferenceOperation parameter: + return parameter.Parameter; + case IFieldReferenceOperation field: + return field.Field; + default: + return null; + } + } + + private static string DescribeBody(string enclosingOperationName) + { + switch (enclosingOperationName) + { + case "WaitForConditionAsync": + return "condition-check"; + case "WaitForCallbackAsync": + return "callback-submitter"; + default: + return "step"; + } + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs new file mode 100644 index 000000000..ea3c00b8d --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallAnalyzer.cs @@ -0,0 +1,77 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Immutable; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Operations; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// DE001 — flags non-deterministic API usage (DateTime.Now, Guid.NewGuid(), Random, …) in + /// durable workflow code that is not inside a step. On replay the workflow re-runs from the top, + /// so such values differ between the original execution and replays. The sanctioned place for + /// non-determinism is inside a step (or a WaitForCallback submitter / WaitForCondition check, + /// which the SDK also runs inside a step), where the result is checkpointed. + /// + [DiagnosticAnalyzer(LanguageNames.CSharp)] + public sealed class NonDeterministicCallAnalyzer : DiagnosticAnalyzer + { + /// + public override ImmutableArray SupportedDiagnostics { get; } = + ImmutableArray.Create(DurableDiagnostics.NonDeterministicCallOutsideStep); + + /// + public override void Initialize(AnalysisContext context) + { + context.EnableConcurrentExecution(); + context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None); + + context.RegisterCompilationStartAction(compilationStart => + { + var symbols = DurableKnownSymbols.TryCreate(compilationStart.Compilation); + if (symbols is null) + { + return; // Project does not reference the durable SDK; nothing to analyze. + } + + compilationStart.RegisterOperationAction( + ctx => AnalyzeOperation(ctx, symbols), + OperationKind.PropertyReference, + OperationKind.Invocation, + OperationKind.ObjectCreation); + }); + } + + private static void AnalyzeOperation(OperationAnalysisContext context, DurableKnownSymbols symbols) + { + var operation = context.Operation; + + var api = symbols.TryGetNonDeterministicApi(operation); + if (api is null) + { + return; + } + + // Only flag inside durable workflow code (a method/lambda taking an IDurableContext). + if (!DurableScope.IsInWorkflowCode(operation, symbols)) + { + return; + } + + // Suppress when the nearest enclosing durable delegate is step-wrapped — non-determinism + // is allowed (and expected) there because its result is checkpointed. + var enclosing = DurableScope.FindNearestDurableDelegate(operation, symbols); + if (enclosing.Found && enclosing.Role == DurableDelegateRole.StepWrapped) + { + return; + } + + context.ReportDiagnostic(Diagnostic.Create( + DurableDiagnostics.NonDeterministicCallOutsideStep, + operation.Syntax.GetLocation(), + api)); + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs new file mode 100644 index 000000000..8d839635c --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution.Analyzers/NonDeterministicCallCodeFixProvider.cs @@ -0,0 +1,231 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeActions; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Formatting; + +namespace Amazon.Lambda.DurableExecution.Analyzers +{ + /// + /// Code fix for DE001: wraps a non-deterministic expression in a step so its value is checkpointed + /// (await context.StepAsync((_, _) => Task.FromResult(E))). Offered as a single-occurrence + /// quick fix only — never a fix-all — because inserting a step shifts the position-derived + /// operation IDs of subsequent durable calls, which would break replay for already-running + /// executions. The fix is withheld for shapes it cannot safely rewrite. + /// + [ExportCodeFixProvider(LanguageNames.CSharp, Name = nameof(NonDeterministicCallCodeFixProvider))] + [Shared] + public sealed class NonDeterministicCallCodeFixProvider : CodeFixProvider + { + private const string IDurableContextMetadataName = DurableKnownSymbols.IDurableContextMetadataName; + + /// + public override ImmutableArray FixableDiagnosticIds { get; } = + ImmutableArray.Create(DurableDiagnostics.NonDeterministicCallOutsideStep.Id); + + /// + /// Fix-all is intentionally disabled: inserting a step shifts the position-derived operation + /// IDs of every subsequent durable call, which would break replay for in-flight executions. + /// + public override FixAllProvider? GetFixAllProvider() => null; + + /// + public override async Task RegisterCodeFixesAsync(CodeFixContext context) + { + var root = await context.Document.GetSyntaxRootAsync(context.CancellationToken).ConfigureAwait(false); + if (root is null) + { + return; + } + + var diagnostic = context.Diagnostics[0]; + var node = root.FindNode(diagnostic.Location.SourceSpan, getInnermostNodeForTie: true); + var expression = node as ExpressionSyntax ?? node.FirstAncestorOrSelf(); + if (expression is null) + { + return; + } + + var semanticModel = await context.Document.GetSemanticModelAsync(context.CancellationToken).ConfigureAwait(false); + if (semanticModel is null) + { + return; + } + + if (!IsSafelyWrappable(expression, semanticModel, context.CancellationToken, out var contextName)) + { + return; + } + + context.RegisterCodeFix( + CodeAction.Create( + title: $"Wrap in {contextName}.StepAsync(...)", + createChangedDocument: ct => WrapInStepAsync(context.Document, root, expression, contextName, ct), + equivalenceKey: "WrapInStepAsync"), + diagnostic); + } + + private static bool IsSafelyWrappable( + ExpressionSyntax expression, + SemanticModel semanticModel, + CancellationToken cancellationToken, + out string contextName) + { + contextName = "context"; + + // Guard: the expression must not itself contain an await, out/ref/in argument, or be a + // method group / lambda — wrapping any of those produces non-compiling or wrong code. + if (expression.DescendantNodesAndSelf().Any(n => n is AwaitExpressionSyntax)) + { + return false; + } + + foreach (var argument in expression.DescendantNodes().OfType()) + { + if (!argument.RefKindKeyword.IsKind(SyntaxKind.None)) + { + return false; + } + } + + // The expression must produce a usable value (object creation of e.g. Random is flagged by + // DE001 but is not meaningfully wrappable into a checkpointed value — skip it). + if (expression is ObjectCreationExpressionSyntax) + { + return false; + } + + var typeInfo = semanticModel.GetTypeInfo(expression, cancellationToken); + if (typeInfo.Type is null || typeInfo.Type.TypeKind == TypeKind.Error || typeInfo.Type.SpecialType == SpecialType.System_Void) + { + return false; + } + + // await is only legal inside an async context; require one. + if (!IsInAsyncContext(expression)) + { + return false; + } + + // Find an in-scope IDurableContext to call StepAsync on. + var ctxName = FindDurableContextName(expression, semanticModel, cancellationToken); + if (ctxName is null) + { + return false; + } + + contextName = ctxName; + return true; + } + + private static bool IsInAsyncContext(SyntaxNode node) + { + for (var current = node.Parent; current is not null; current = current.Parent) + { + switch (current) + { + case MethodDeclarationSyntax m: + return m.Modifiers.Any(SyntaxKind.AsyncKeyword); + case LocalFunctionStatementSyntax lf: + return lf.Modifiers.Any(SyntaxKind.AsyncKeyword); + case AnonymousFunctionExpressionSyntax af: + return af.AsyncKeyword.IsKind(SyntaxKind.AsyncKeyword); + } + } + + return false; + } + + private static string? FindDurableContextName( + ExpressionSyntax expression, + SemanticModel semanticModel, + CancellationToken cancellationToken) + { + var durableContextType = semanticModel.Compilation.GetTypeByMetadataName(IDurableContextMetadataName); + if (durableContextType is null) + { + return null; + } + + // Walk enclosing lambdas/methods looking for an IDurableContext-typed parameter. + for (var current = expression.Parent; current is not null; current = current.Parent) + { + var parameters = current switch + { + ParenthesizedLambdaExpressionSyntax pl => pl.ParameterList.Parameters, + SimpleLambdaExpressionSyntax sl => SyntaxFactory.SeparatedList(new[] { sl.Parameter }), + MethodDeclarationSyntax m => m.ParameterList.Parameters, + LocalFunctionStatementSyntax lf => lf.ParameterList.Parameters, + _ => default, + }; + + foreach (var parameter in parameters) + { + var symbol = semanticModel.GetDeclaredSymbol(parameter, cancellationToken) as IParameterSymbol; + if (symbol is not null && Implements(symbol.Type, durableContextType)) + { + return symbol.Name; + } + } + } + + return null; + } + + private static bool Implements(ITypeSymbol type, INamedTypeSymbol durableContextType) + { + if (SymbolEqualityComparer.Default.Equals(type.OriginalDefinition, durableContextType)) + { + return true; + } + + return type.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i.OriginalDefinition, durableContextType)); + } + + private static Task WrapInStepAsync( + Document document, + SyntaxNode root, + ExpressionSyntax expression, + string contextName, + CancellationToken cancellationToken) + { + // await {ctx}.StepAsync((_, _) => System.Threading.Tasks.Task.FromResult(E)) + var lambda = SyntaxFactory.ParenthesizedLambdaExpression( + SyntaxFactory.ParameterList(SyntaxFactory.SeparatedList(new[] + { + SyntaxFactory.Parameter(SyntaxFactory.Identifier("_")), + SyntaxFactory.Parameter(SyntaxFactory.Identifier("__")), + })), + SyntaxFactory.InvocationExpression( + SyntaxFactory.ParseExpression("System.Threading.Tasks.Task.FromResult"), + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(expression.WithoutTrivia()))))); + + var stepCall = SyntaxFactory.AwaitExpression( + SyntaxFactory.InvocationExpression( + SyntaxFactory.MemberAccessExpression( + SyntaxKind.SimpleMemberAccessExpression, + SyntaxFactory.IdentifierName(contextName), + SyntaxFactory.IdentifierName("StepAsync")), + SyntaxFactory.ArgumentList(SyntaxFactory.SingletonSeparatedList( + SyntaxFactory.Argument(lambda))))); + + var replacement = stepCall + .WithLeadingTrivia(expression.GetLeadingTrivia()) + .WithTrailingTrivia(expression.GetTrailingTrivia()) + .WithAdditionalAnnotations(Formatter.Annotation); + + var newRoot = root.ReplaceNode(expression, replacement); + return Task.FromResult(document.WithSyntaxRoot(newRoot)); + } + } +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj index cb03b2715..4ba44dfc1 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Amazon.Lambda.DurableExecution.csproj @@ -6,7 +6,7 @@ $(DefaultPackageTargets) Amazon Lambda .NET SDK for Durable Execution - write multi-step workflows that persist state automatically. Amazon.Lambda.DurableExecution - 0.1.1-preview + 0.1.2-preview Amazon.Lambda.DurableExecution Amazon.Lambda.DurableExecution AWS;Amazon;Lambda;Durable;Workflow @@ -32,6 +32,26 @@ + + + ..\Amazon.Lambda.DurableExecution.Analyzers\ + + + + + + + diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs index 04ebee556..960be20b4 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/DurableFunction.cs @@ -37,7 +37,8 @@ public static Task WrapAsync( Func> workflow, DurableExecutionInvocationInput invocationInput, ILambdaContext lambdaContext) - => WrapAsyncCore(workflow, invocationInput, lambdaContext, _cachedLambdaClient.Value); + => WrapAsyncCore(workflow, invocationInput, lambdaContext, + new LambdaDurableServiceClient(_cachedLambdaClient.Value)); /// /// Wrap a workflow (typed input + output) with explicit Lambda client. @@ -47,7 +48,8 @@ public static Task WrapAsync( DurableExecutionInvocationInput invocationInput, ILambdaContext lambdaContext, IAmazonLambda lambdaClient) - => WrapAsyncCore(workflow, invocationInput, lambdaContext, lambdaClient); + => WrapAsyncCore(workflow, invocationInput, lambdaContext, + new LambdaDurableServiceClient(lambdaClient)); /// /// Wrap a void workflow (typed input, no output). @@ -68,20 +70,32 @@ public static Task WrapAsync( IAmazonLambda lambdaClient) => WrapAsyncCore( async (input, ctx) => { await workflow(input, ctx); return null; }, - invocationInput, lambdaContext, lambdaClient); + invocationInput, lambdaContext, + new LambdaDurableServiceClient(lambdaClient)); + + /// + /// Internal overload for the testing package — accepts an + /// directly so the testing SDK can + /// inject an in-memory implementation. + /// + internal static Task WrapAsync( + Func> workflow, + DurableExecutionInvocationInput invocationInput, + ILambdaContext lambdaContext, + IDurableServiceClient serviceClient) + => WrapAsyncCore(workflow, invocationInput, lambdaContext, serviceClient); private static async Task WrapAsyncCore( Func> workflow, DurableExecutionInvocationInput invocationInput, ILambdaContext lambdaContext, - IAmazonLambda lambdaClient) + IDurableServiceClient serviceClient) { var serializer = LambdaSerializerHelper.GetRequired(lambdaContext); var state = new ExecutionState(); state.LoadFromCheckpoint(invocationInput.InitialExecutionState); - var serviceClient = new LambdaDurableServiceClient(lambdaClient); var checkpointToken = invocationInput.CheckpointToken; var nextMarker = invocationInput.InitialExecutionState?.NextMarker; diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Services/IDurableServiceClient.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Services/IDurableServiceClient.cs new file mode 100644 index 000000000..8ccbf13c9 --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Services/IDurableServiceClient.cs @@ -0,0 +1,27 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using SdkOperationUpdate = Amazon.Lambda.Model.OperationUpdate; + +namespace Amazon.Lambda.DurableExecution.Services; + +/// +/// Abstraction over the durable execution service RPCs. The production +/// implementation () calls the real +/// AWS Lambda APIs; the testing package injects an in-memory fake. +/// +internal interface IDurableServiceClient +{ + Task CheckpointAsync( + string durableExecutionArn, + string? checkpointToken, + IReadOnlyList pendingOperations, + Action>? onNewOperations = null, + CancellationToken cancellationToken = default); + + Task<(List Operations, string? NextMarker)> GetExecutionStateAsync( + string durableExecutionArn, + string? checkpointToken, + string marker, + CancellationToken cancellationToken = default); +} diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs b/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs index 7fae2ee10..818e95bd0 100644 --- a/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs +++ b/Libraries/src/Amazon.Lambda.DurableExecution/Services/LambdaDurableServiceClient.cs @@ -19,7 +19,7 @@ namespace Amazon.Lambda.DurableExecution.Services; /// /// Calls the real AWS Lambda Durable Execution APIs via the AWSSDK.Lambda client. /// -internal sealed class LambdaDurableServiceClient +internal sealed class LambdaDurableServiceClient : IDurableServiceClient { private readonly IAmazonLambda _lambdaClient; diff --git a/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md b/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md new file mode 100644 index 000000000..bc498c10b --- /dev/null +++ b/Libraries/src/Amazon.Lambda.DurableExecution/docs/analyzers.md @@ -0,0 +1,130 @@ +# Durable Execution Analyzers (DE001–DE004) + +`Amazon.Lambda.DurableExecution` ships a set of Roslyn analyzers that run in the IDE and during +`dotnet build` to catch the most common durable-execution authoring mistakes before they become +confusing runtime failures. The analyzers are bundled inside the `Amazon.Lambda.DurableExecution` +NuGet package (`analyzers/dotnet/cs`), so they activate automatically for any project that references +the package — no extra reference is required. They only run on projects that reference the durable SDK +and only inside durable *workflow code* (a method, local function, or lambda that takes an +`IDurableContext` parameter), so unrelated code in the same project is unaffected. + +These analyzers are the .NET counterpart of the JavaScript SDK's +[`@aws/durable-execution-sdk-js-eslint-plugin`](https://github.com/aws/aws-durable-execution-sdk-js/tree/main/packages/aws-durable-execution-sdk-js-eslint-plugin), +re-implemented with Roslyn's semantic model so durable operations are matched by symbol +(`Amazon.Lambda.DurableExecution.IDurableContext`) rather than by method name. + +## Why determinism matters + +A durable workflow has no persisted program counter. On every invocation — including every replay +after a wait, retry, or failure — your handler runs again from the top. Each durable call +(`StepAsync`, `WaitAsync`, …) looks up its checkpoint and either replays the cached result or runs +fresh. For the cached results to line up with the code, the workflow must execute the **same +operations, in the same order, every time**. Code *outside* a step must therefore be deterministic; +code *inside* a step may be non-deterministic because the step's result is checkpointed once. + +## Rules + +| ID | Severity | Rule | +|-------|----------|------| +| DE001 | Warning | Non-deterministic call outside a step | +| DE002 | Warning | Nested durable operation inside a step body | +| DE003 | Warning | Mutable variable captured and modified inside a durable operation | +| DE004 | Info | `Task.WhenAll`/`Task.WhenAny` over durable tasks | + +### DE001 — Non-deterministic call outside a step + +Flags `DateTime.Now`/`UtcNow`/`Today`, `DateTimeOffset.Now`/`UtcNow`, `Guid.NewGuid()`, +`new Random()` / `Random.Shared`, `Stopwatch.GetTimestamp()`/`StartNew()`/`Elapsed*`, +`Environment.TickCount`/`TickCount64`, `RandomNumberGenerator`/`RNGCryptoServiceProvider`, and +`Path.GetTempFileName()`/`GetRandomFileName()` when used in workflow code outside a step. The value +would differ between the original execution and replays, corrupting checkpoint-derived state. A +seeded `new Random(42)` is deterministic and is **not** flagged. + +```csharp +// ❌ Flagged — different value on replay +var now = DateTime.UtcNow; +await context.StepAsync((s, ct) => DoWork(now)); + +// ✅ Captured inside a step +var now = await context.StepAsync((s, ct) => Task.FromResult(DateTime.UtcNow)); +await context.StepAsync((s, ct) => DoWork(now)); +``` + +A code fix is offered that wraps the expression in `context.StepAsync(...)`. It is a single-occurrence +quick fix only (no Fix All), because inserting a step shifts the position-derived operation IDs of +subsequent durable calls and would break replay for already-running executions. + +### DE002 — Nested durable operation inside a step body + +Flags any durable operation (`StepAsync`, `WaitAsync`, `ParallelAsync`, `MapAsync`, `InvokeAsync`, +`RunInChildContextAsync`, `CreateCallbackAsync`, `WaitForCallbackAsync`) invoked inside a *step-wrapped* +delegate — a `StepAsync` body, a `WaitForCallbackAsync` submitter, or a `WaitForConditionAsync` check — +by capturing the outer `IDurableContext`. Step bodies are leaf operations; group durable operations +with `RunInChildContextAsync` instead. + +```csharp +// ❌ Flagged — durable op nested in a step body +await context.StepAsync(async (s, ct) => +{ + await context.WaitAsync(TimeSpan.FromSeconds(1)); // uses the captured outer context +}); + +// ✅ Group with a child context +await context.RunInChildContextAsync(async (child, ct) => +{ + await child.StepAsync((s, c) => DoWork()); + await child.WaitAsync(TimeSpan.FromSeconds(1)); +}); +``` + +### DE003 — Mutable variable captured and modified inside a durable operation + +Flags assignment, compound assignment, or increment/decrement of a variable captured from an outer +scope inside a durable-operation delegate (`StepAsync`, `RunInChildContextAsync`, +`WaitForConditionAsync`, `WaitForCallbackAsync`, and `ParallelAsync`/`MapAsync` branches). On replay the +body is skipped and the cached result returned, so the write never happens. Reading a captured variable +is safe and is not flagged. + +```csharp +// ❌ Flagged — write is lost on replay +int total = 0; +await context.StepAsync((s, ct) => { total += 1; return Task.CompletedTask; }); + +// ✅ Return the value and assign it +total = await context.StepAsync((s, ct) => Task.FromResult(total + 1)); +``` + +### DE004 — `Task.WhenAll`/`Task.WhenAny` over durable tasks + +Advisory (Info). `Task.WhenAll`/`Task.WhenAny` work correctly with durable tasks (operation IDs are +allocated deterministically), but they bypass completion policies, concurrency limits, branch naming, +and structured `IBatchResult` output. Prefer `ParallelAsync`/`MapAsync`. + +```csharp +// ℹ️ Suggested — works, but prefer ParallelAsync +await Task.WhenAll(context.StepAsync(a), context.StepAsync(b)); + +// ✅ Preferred +await context.ParallelAsync(new Func>[] +{ + (c, ct) => c.StepAsync((s, t) => A()), + (c, ct) => c.StepAsync((s, t) => B()), +}); +``` + +A code fix converts the simplest safe shape (an inline list of same-typed durable calls on one context +whose aggregate result is discarded) into `ParallelAsync`. + +## Configuring severity + +Each rule's severity can be overridden per project via `.editorconfig`: + +```ini +# Treat the non-determinism rule as an error, silence the WhenAll suggestion. +dotnet_diagnostic.DE001.severity = error +dotnet_diagnostic.DE004.severity = none +``` + +Note: if your project sets `true`, the Warning-level +rules (DE001–DE003) will fail the build. Lower their severity in `.editorconfig` if you prefer them as +warnings during the preview. diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj new file mode 100644 index 000000000..a9f3df12c --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Amazon.Lambda.DurableExecution.Analyzers.Tests.csproj @@ -0,0 +1,31 @@ + + + + net8.0 + latest + enable + false + false + + $(NoWarn);CS0618 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs new file mode 100644 index 000000000..2e7c36ebd --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableStubs.cs @@ -0,0 +1,67 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + /// + /// A faithful (signature-only) source copy of the durable-execution surface the analyzers match + /// against. Injected into every test compilation as source so tests do not need to reference the + /// real Amazon.Lambda.DurableExecution package (which pulls AWSSDK and is awkward in the analyzer + /// test harness). The analyzers resolve these by metadata name, so the stub namespace and member + /// shapes must match the real SDK exactly. + /// + internal static class DurableStubs + { + internal const string Source = @" +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Amazon.Lambda.DurableExecution +{ + public interface IStepContext { int AttemptNumber { get; } string OperationId { get; } } + public interface IConditionCheckContext { } + public interface IWaitForCallbackContext { } + public interface IExecutionContext { string DurableExecutionArn { get; } } + public interface ICallback { string CallbackId { get; } Task GetResultAsync(CancellationToken ct = default); } + + public sealed class StepConfig { } + public sealed class ChildContextConfig { } + public sealed class CallbackConfig { } + public sealed class WaitForCallbackConfig { } + public sealed class InvokeConfig { } + public sealed class ParallelConfig { } + public sealed class MapConfig { } + public sealed class WaitForConditionConfig { } + public readonly struct DurableBranch { public DurableBranch(string name, Func> func) { } } + public interface IBatchResult { } + + public interface IDurableContext + { + IExecutionContext ExecutionContext { get; } + + Task StepAsync(Func> func, string name = null, StepConfig config = null, CancellationToken cancellationToken = default); + Task StepAsync(Func func, string name = null, StepConfig config = null, CancellationToken cancellationToken = default); + + Task WaitAsync(TimeSpan duration, string name = null, CancellationToken cancellationToken = default); + + Task RunInChildContextAsync(Func> func, string name = null, ChildContextConfig config = null, CancellationToken cancellationToken = default); + Task RunInChildContextAsync(Func func, string name = null, ChildContextConfig config = null, CancellationToken cancellationToken = default); + + Task> CreateCallbackAsync(string name = null, CallbackConfig config = null, CancellationToken cancellationToken = default); + Task WaitForCallbackAsync(Func submitter, string name = null, WaitForCallbackConfig config = null, CancellationToken cancellationToken = default); + + Task InvokeAsync(string functionName, TPayload payload, string name = null, InvokeConfig config = null, CancellationToken cancellationToken = default); + + Task WaitForConditionAsync(Func> check, WaitForConditionConfig config, string name = null, CancellationToken cancellationToken = default); + + Task> ParallelAsync(IReadOnlyList>> branches, string name = null, ParallelConfig config = null, CancellationToken cancellationToken = default); + Task> ParallelAsync(IReadOnlyList> branches, string name = null, ParallelConfig config = null, CancellationToken cancellationToken = default); + + Task> MapAsync(IReadOnlyList items, Func, CancellationToken, Task> func, string name = null, MapConfig config = null, CancellationToken cancellationToken = default); + } +} +"; + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs new file mode 100644 index 000000000..d79e02e31 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorAnalyzerTests.cs @@ -0,0 +1,108 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier< + Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorAnalyzer>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class DurableTaskCombinatorAnalyzerTests + { + private const string Usings = @" +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task WhenAll_OverInlineDurableTasks_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await {|#0:Task.WhenAll( + context.StepAsync((s, ct) => Task.FromResult(1)), + context.StepAsync((s, ct) => Task.FromResult(2)))|}; + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator) + .WithLocation(0).WithArguments("Task.WhenAll"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task WhenAny_OverInlineDurableTasks_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await {|#0:Task.WhenAny( + context.StepAsync((s, ct) => Task.FromResult(1)), + context.StepAsync((s, ct) => Task.FromResult(2)))|}; + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator) + .WithLocation(0).WithArguments("Task.WhenAny"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task WhenAll_OverTaskArrayLocal_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var tasks = new List>(); + tasks.Add(context.StepAsync((s, ct) => Task.FromResult(1))); + await {|#0:Task.WhenAll(tasks)|}; + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator) + .WithLocation(0).WithArguments("Task.WhenAll"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task WhenAll_OverNonDurableTasks_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await Task.WhenAll(Task.Delay(1), Task.Delay(2)); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task ParallelAsync_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.ParallelAsync(new Func>[] + { + (c, ct) => c.StepAsync((s, t) => Task.FromResult(1)), + (c, ct) => c.StepAsync((s, t) => Task.FromResult(2)), + }); + } +}"; + await Verify.VerifyAsync(source); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs new file mode 100644 index 000000000..12e7a4d28 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/DurableTaskCombinatorCodeFixTests.cs @@ -0,0 +1,49 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.CodeFixVerifier< + Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorAnalyzer, + Amazon.Lambda.DurableExecution.Analyzers.DurableTaskCombinatorCodeFixProvider>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class DurableTaskCombinatorCodeFixTests + { + private const string Usings = @" +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task ConvertsWhenAll_ToParallelAsync_WhenResultDiscarded() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await {|#0:Task.WhenAll( + context.StepAsync((s, ct) => Task.FromResult(1)), + context.StepAsync((s, ct) => Task.FromResult(2)))|}; + } +}"; + var fixedSource = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.ParallelAsync(new System.Func>[] { (c, ct) => c.StepAsync((s, ct) => Task.FromResult(1)), (c, ct) => c.StepAsync((s, ct) => Task.FromResult(2)) }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.DurableTaskInTaskCombinator) + .WithLocation(0).WithArguments("Task.WhenAll"); + await Verify.VerifyAsync(source, fixedSource, expected); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs new file mode 100644 index 000000000..52c487f78 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/MutableCaptureAnalyzerTests.cs @@ -0,0 +1,184 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier< + Amazon.Lambda.DurableExecution.Analyzers.MutableCaptureAnalyzer>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class MutableCaptureAnalyzerTests + { + private const string Usings = @" +using System; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task SimpleAssignment_OfCapturedVariable_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int counter = 0; + await context.StepAsync((s, ct) => { {|#0:counter|} = 5; return Task.CompletedTask; }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation) + .WithLocation(0).WithArguments("counter"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task CompoundAssignment_OfCapturedVariable_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int total = 0; + await context.StepAsync((s, ct) => { {|#0:total|} += 1; return Task.CompletedTask; }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation) + .WithLocation(0).WithArguments("total"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task Increment_OfCapturedVariable_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int n = 0; + await context.StepAsync((s, ct) => { {|#0:n|}++; return Task.CompletedTask; }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation) + .WithLocation(0).WithArguments("n"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task ReadingCapturedVariable_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int seed = 41; + await context.StepAsync((s, ct) => Task.FromResult(seed + 1)); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task MutatingDelegateLocal_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync((s, ct) => + { + int local = 0; + local += 1; + return Task.FromResult(local); + }); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task ShadowingLocal_IsNotFlagged() + { + // An inner local that shadows an outer name is delegate-local by symbol identity. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int total = 100; + await context.StepAsync((s, ct) => + { + int total2 = 0; + total2 += 1; + return Task.FromResult(total2); + }); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task MutatingCapturedVariable_InChildContext_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int flag = 0; + await context.RunInChildContextAsync((child, ct) => { {|#0:flag|} = 1; return Task.CompletedTask; }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation) + .WithLocation(0).WithArguments("flag"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task MutatingPerItemAndBranchLocals_InParallel_IsNotFlagged() + { + // The branch delegate's own parameters and locals are not captures. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.ParallelAsync(new Func>[] + { + (branch, ct) => { int x = 0; x += 1; return Task.FromResult(x); }, + (branch, ct) => Task.FromResult(2), + }); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task MutatingCapturedVariable_FromParallelBranch_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + int shared = 0; + await context.ParallelAsync(new Func>[] + { + (branch, ct) => { {|#0:shared|} += 1; return Task.FromResult(shared); }, + }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.MutableCaptureInDurableOperation) + .WithLocation(0).WithArguments("shared"); + await Verify.VerifyAsync(source, expected); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs new file mode 100644 index 000000000..6493dba53 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NestedDurableOperationAnalyzerTests.cs @@ -0,0 +1,153 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier< + Amazon.Lambda.DurableExecution.Analyzers.NestedDurableOperationAnalyzer>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class NestedDurableOperationAnalyzerTests + { + private const string Usings = @" +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task DurableOp_InsideStepBody_ViaCapturedContext_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync(async (s, ct) => + { + await {|#0:context.WaitAsync(TimeSpan.FromSeconds(1))|}; + }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep) + .WithLocation(0).WithArguments("WaitAsync", "context", "step"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task NestedStepAsync_InsideStepBody_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync(async (s, ct) => + { + await {|#0:context.StepAsync((s2, ct2) => Task.CompletedTask)|}; + }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep) + .WithLocation(0).WithArguments("StepAsync", "context", "step"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task DurableOp_InsideChildContext_OnChildContext_IsNotFlagged() + { + // Using the child's own context inside a child context is the SANCTIONED nesting pattern. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.RunInChildContextAsync(async (child, ct) => + { + await child.StepAsync((s, c) => Task.CompletedTask); + await child.WaitAsync(TimeSpan.FromSeconds(1)); + }); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task DurableOp_DirectlyInWorkflowBody_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync((s, ct) => Task.CompletedTask); + await context.WaitAsync(TimeSpan.FromSeconds(1)); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task DurableOp_InsideConditionCheck_ViaCapturedContext_IsFlagged() + { + // WaitForConditionAsync's check delegate is step-wrapped at runtime. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.WaitForConditionAsync(async (state, cc, ct) => + { + await {|#0:context.WaitAsync(TimeSpan.FromSeconds(1))|}; + return state + 1; + }, new WaitForConditionConfig()); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep) + .WithLocation(0).WithArguments("WaitAsync", "context", "condition-check"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task DurableOp_InsideCallbackSubmitter_ViaCapturedContext_IsFlagged() + { + // WaitForCallbackAsync's submitter delegate is step-wrapped at runtime. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.WaitForCallbackAsync(async (callbackId, cc, ct) => + { + await {|#0:context.StepAsync((s, c) => Task.CompletedTask)|}; + }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NestedDurableOperationInsideStep) + .WithLocation(0).WithArguments("StepAsync", "context", "callback-submitter"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task ConfigureLogger_LikeMembers_NotTreatedAsDurableOps() + { + // Reading ExecutionContext (a property, not a durable op) inside a step is fine. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync((s, ct) => + { + var arn = context.ExecutionContext.DurableExecutionArn; + return Task.FromResult(arn); + }); + } +}"; + await Verify.VerifyAsync(source); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs new file mode 100644 index 000000000..144344689 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallAnalyzerTests.cs @@ -0,0 +1,211 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.AnalyzerVerifier< + Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallAnalyzer>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class NonDeterministicCallAnalyzerTests + { + private const string Usings = @" +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task DateTimeNow_InWorkflowBody_OutsideStep_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var now = {|#0:DateTime.Now|}; + await context.StepAsync((s, ct) => Task.FromResult(now)); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("DateTime.Now"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task DateTimeUtcNow_InsideStep_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync((s, ct) => Task.FromResult(DateTime.UtcNow)); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task GuidNewGuid_OutsideStep_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var id = {|#0:Guid.NewGuid()|}; + await context.StepAsync((s, ct) => Task.FromResult(id.ToString())); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("Guid.NewGuid()"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task NewRandom_OutsideStep_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var r = {|#0:new Random()|}; + await context.StepAsync((s, ct) => Task.FromResult(r.Next())); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("new Random()"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task SeededRandom_IsNotFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var r = new Random(42); + await context.StepAsync((s, ct) => Task.FromResult(r.Next())); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task RandomShared_OutsideStep_IsFlagged() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var n = {|#0:Random.Shared|}.Next(); + await context.StepAsync((s, ct) => Task.FromResult(n)); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("Random.Shared"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task NonDeterminism_NestedInNonDurableLambdaInsideStep_IsNotFlagged() + { + // DateTime.UtcNow inside a LINQ projection that itself runs inside a step is fine — + // the enclosing durable delegate (StepAsync) is step-wrapped. + var source = Usings + @" +using System.Linq; +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.StepAsync((s, ct) => + { + var times = Enumerable.Range(0, 3).Select(x => DateTime.UtcNow).ToArray(); + return Task.FromResult(times.Length); + }); + } +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task DateTimeNow_InNonWorkflowMethod_IsNotFlagged() + { + // A method with no IDurableContext parameter is not workflow code. + var source = Usings + @" +class W +{ + public DateTime Helper() => DateTime.Now; +}"; + await Verify.VerifyAsync(source); + } + + [Fact] + public async Task DateTimeNow_InsideChildContextBranch_IsFlagged() + { + // A child-context body is still workflow code that must be deterministic. + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + await context.RunInChildContextAsync(async (child, ct) => + { + var now = {|#0:DateTime.Now|}; + await child.StepAsync((s, c) => Task.FromResult(now)); + }); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("DateTime.Now"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task UnrelatedStepMethodOnOtherType_DoesNotSuppress() + { + // Semantic matching: an unrelated type's StepAsync must NOT be treated as a durable step, + // so it does not suppress non-determinism. DateTime.Now nested in it is still workflow code + // outside any real durable step, so it IS flagged. + var source = Usings + @" +class NotDurable { public Task StepAsync(Func> f) => f(); } +class W +{ + public async Task Run(string input, IDurableContext context) + { + var other = new NotDurable(); + await other.StepAsync(() => Task.FromResult({|#0:DateTime.Now|})); + await context.StepAsync((s, ct) => Task.CompletedTask); + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("DateTime.Now"); + await Verify.VerifyAsync(source, expected); + } + + [Fact] + public async Task UnrelatedTypeProducesNoDiagnostic_OutsideWorkflow() + { + // The same unrelated StepAsync, but in a method with no IDurableContext — not workflow code. + var source = Usings + @" +class NotDurable { public Task StepAsync(Func> f) => f(); } +class W +{ + public async Task Helper() + { + var other = new NotDurable(); + await other.StepAsync(() => Task.FromResult(DateTime.Now)); + } +}"; + await Verify.VerifyAsync(source); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs new file mode 100644 index 000000000..ccedd3171 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/NonDeterministicCallCodeFixTests.cs @@ -0,0 +1,48 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Testing; +using Xunit; +using Verify = Amazon.Lambda.DurableExecution.Analyzers.Tests.CodeFixVerifier< + Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallAnalyzer, + Amazon.Lambda.DurableExecution.Analyzers.NonDeterministicCallCodeFixProvider>; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + public class NonDeterministicCallCodeFixTests + { + private const string Usings = @" +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon.Lambda.DurableExecution; +"; + + [Fact] + public async Task WrapsDateTimeNow_InStepAsync() + { + var source = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var now = {|#0:DateTime.UtcNow|}; + return now; + } +}"; + var fixedSource = Usings + @" +class W +{ + public async Task Run(string input, IDurableContext context) + { + var now = await context.StepAsync((_, __) => System.Threading.Tasks.Task.FromResult(DateTime.UtcNow)); + return now; + } +}"; + var expected = Verify.Diagnostic(DurableDiagnostics.NonDeterministicCallOutsideStep) + .WithLocation(0).WithArguments("DateTime.UtcNow"); + await Verify.VerifyAsync(source, fixedSource, expected); + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs new file mode 100644 index 000000000..4e5e77810 --- /dev/null +++ b/Libraries/test/Amazon.Lambda.DurableExecution.Analyzers.Tests/Verifiers.cs @@ -0,0 +1,74 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.CodeFixes; +using Microsoft.CodeAnalysis.CSharp.Testing; +using Microsoft.CodeAnalysis.Diagnostics; +using Microsoft.CodeAnalysis.Testing; +using Microsoft.CodeAnalysis.Testing.Verifiers; + +namespace Amazon.Lambda.DurableExecution.Analyzers.Tests +{ + /// + /// Analyzer-only verifier that injects the durable stub surface into every test compilation and + /// targets the net8.0 reference assemblies. + /// + internal static class AnalyzerVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + { + internal static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) => + new DiagnosticResult(descriptor); + + internal static Task VerifyAsync(string source, params DiagnosticResult[] expected) + { + var test = new Test { TestCode = source }; + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(CancellationToken.None); + } + + private sealed class Test : CSharpAnalyzerTest + { + public Test() + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80; + TestState.Sources.Add(DurableStubs.Source); + } + } + } + + /// + /// Code-fix verifier mirroring with the stub injected + /// into both the pre-fix and post-fix compilations. + /// + internal static class CodeFixVerifier + where TAnalyzer : DiagnosticAnalyzer, new() + where TCodeFix : CodeFixProvider, new() + { + internal static DiagnosticResult Diagnostic(DiagnosticDescriptor descriptor) => + new DiagnosticResult(descriptor); + + internal static Task VerifyAsync(string source, string fixedSource, params DiagnosticResult[] expected) + { + var test = new Test + { + TestCode = source, + FixedCode = fixedSource, + }; + test.ExpectedDiagnostics.AddRange(expected); + return test.RunAsync(CancellationToken.None); + } + + private sealed class Test : CSharpCodeFixTest + { + public Test() + { + ReferenceAssemblies = ReferenceAssemblies.Net.Net80; + TestState.Sources.Add(DurableStubs.Source); + FixedState.Sources.Add(DurableStubs.Source); + } + } + } +} diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj index 7eb196bf1..5091e5bf7 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/Amazon.Lambda.DurableExecution.IntegrationTests.csproj @@ -4,7 +4,7 @@ - $(DefaultPackageTargets) + net10.0 enable enable false diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackFailedTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackFailedTest.cs index 3a1e6c2c9..f29937dfe 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackFailedTest.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackFailedTest.cs @@ -46,13 +46,17 @@ public async Task CallbackFailed_SurfacesAsCallbackFailedException() var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(120)); Assert.Equal("FAILED", status, ignoreCase: true); - // The workflow's surfaced exception is CallbackFailedException — the SDK - // wraps the external error message into the exception's Message. Verify - // the recorded error type is the SDK's CallbackFailedException and that - // the original failure message survives. + // The workflow surfaces CallbackFailedException to the user, but the + // terminal ErrorObject records the ORIGINAL error identity reported by the + // external system — ErrorObject.FromException deliberately unwraps + // CallbackException to the underlying error (here the RejecterFunction's + // "ApprovalRejected" type), matching the Java/Python/JS SDKs and the + // unwrapping covered by ModelsTests.ErrorObject_FromException_UnwrapsCallbackException. + // Verify the recorded type is the external error type and the original + // failure message survives. var execution = await deployment.GetExecutionAsync(arn!); Assert.NotNull(execution.Error); - Assert.Equal(typeof(CallbackFailedException).FullName, execution.Error.ErrorType); + Assert.Equal("ApprovalRejected", execution.Error.ErrorType); Assert.Contains("rejected", execution.Error.ErrorMessage); // History records both Started and Failed for the same callback. diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackTimeoutTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackTimeoutTest.cs index 7f50091c9..820d843f9 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackTimeoutTest.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/CallbackTimeoutTest.cs @@ -47,12 +47,18 @@ public async Task CallbackTimeout_SurfacesAsCallbackTimeoutException() var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(120)); Assert.Equal("FAILED", status, ignoreCase: true); - // The execution surfaces the SDK's CallbackTimeoutException to the user. - // ErrorObject.FromException records ErrorType as the FullName; verify both - // the type and that the recorded message mentions "timed out". + // The workflow surfaces CallbackTimeoutException to the user, but the + // terminal ErrorObject records the ORIGINAL/underlying error identity — + // ErrorObject.FromException deliberately unwraps CallbackException to the + // error the service recorded on the timed-out callback (the service's + // "Callback.Timeout" code), matching the Java/Python/JS SDKs and the + // unwrapping covered by ModelsTests.ErrorObject_FromException_UnwrapsCallbackException. + // Sibling failure tests (ChildContextFailsTest, InvokeFailureTest) assert + // the same underlying-type contract. Verify the recorded type is the + // service timeout code and the message still mentions "timed out". var execution = await deployment.GetExecutionAsync(arn!); Assert.NotNull(execution.Error); - Assert.Equal(typeof(CallbackTimeoutException).FullName, execution.Error.ErrorType); + Assert.Equal("Callback.Timeout", execution.Error.ErrorType); Assert.Contains("timed out", execution.Error.ErrorMessage, StringComparison.OrdinalIgnoreCase); // History records both Started and TimedOut for the same callback. diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ClassLibraryTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ClassLibraryTest.cs index 3a2897ce7..d809e56d5 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ClassLibraryTest.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ClassLibraryTest.cs @@ -10,7 +10,7 @@ namespace Amazon.Lambda.DurableExecution.IntegrationTests; /// -/// Proves a durable function works on the managed dotnet10 runtime using the +/// Proves a durable function works on a managed dotnet runtime using the /// class-library programming model — a plain Handler method with no /// Main/LambdaBootstrap loop, deployed via an Assembly::Type::Method /// handler string. Confirms the RuntimeSupport durable-execution changes are live in diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs index 7d005d710..cd7448171 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/DurableFunctionDeployment.cs @@ -19,14 +19,17 @@ namespace Amazon.Lambda.DurableExecution.IntegrationTests; /// All resources are torn down on DisposeAsync. /// /// -/// Durable functions deploy as a plain zip package on the managed dotnet10 runtime +/// Durable functions deploy as a plain zip package on a managed dotnet runtime /// (executable model, Handler=bootstrap) — no container image or ECR repository /// required. Each test function is published framework-dependent for linux-x64 and zipped. +/// This harness pins a single managed runtime (see ) for CI; +/// durable execution itself is not tied to that specific runtime version. /// internal sealed class DurableFunctionDeployment : IAsyncDisposable { - // The managed dotnet runtime that supports durable configuration. Executable model: + // The managed dotnet runtime this harness deploys against. Executable model: // `dotnet publish` emits a native `bootstrap` shim and the runtime execs it. + // This is just the runtime CI exercises — durable execution is not tied to it. private const string ManagedRuntime = "dotnet10"; private const string BootstrapHandler = "bootstrap"; diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MapFailureToleranceTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MapFailureToleranceTest.cs index 06ab716c0..25d773f03 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MapFailureToleranceTest.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/MapFailureToleranceTest.cs @@ -61,9 +61,18 @@ public async Task Map_FailureToleranceExceeded_FailsWorkflow() events.Count(e => e.EventType == EventType.ContextFailed) >= 2, $"Expected >= 2 ContextFailed events; got {events.Count(e => e.EventType == EventType.ContextFailed)}"); - // The parent context (named "tolerance") records the aggregate failure. - var parentFailed = events.FirstOrDefault(e => + // The parent context (named "tolerance") is checkpointed ContextSucceeded + // even when the failure tolerance is exceeded: ConcurrentOperation always + // writes the parent batch summary with action SUCCEED (the completion + // reason lives inside the payload), then the SDK throws MapException + // AFTER the checkpoint. This matches the Python/JS/Java wire format. The + // workflow-level failure is asserted above via PollForCompletionAsync == + // FAILED and the MapException error type — the parent CONTEXT itself is + // NOT recorded as ContextFailed. + var parentSucceeded = events.FirstOrDefault(e => + e.EventType == EventType.ContextSucceeded && e.Name == "tolerance"); + Assert.NotNull(parentSucceeded); + Assert.DoesNotContain(events, e => e.EventType == EventType.ContextFailed && e.Name == "tolerance"); - Assert.NotNull(parentFailed); } } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ParallelFailureToleranceTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ParallelFailureToleranceTest.cs index 9ee25ac2f..46d6de202 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ParallelFailureToleranceTest.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/ParallelFailureToleranceTest.cs @@ -60,14 +60,23 @@ public async Task Parallel_FailureToleranceExceeded_FailsWorkflow() var events = history.Events ?? new List(); // At least 2 branches failed (the third may or may not have been - // dispatched depending on race; the parent CONTEXT itself also fails). + // dispatched depending on race). Assert.True( events.Count(e => e.EventType == EventType.ContextFailed) >= 2, $"Expected >= 2 ContextFailed events; got {events.Count(e => e.EventType == EventType.ContextFailed)}"); - // The parent context (named "tolerance") records the aggregate failure. - var parentFailed = events.FirstOrDefault(e => + // The parent context (named "tolerance") is checkpointed ContextSucceeded + // even when the failure tolerance is exceeded: ConcurrentOperation always + // writes the parent batch summary with action SUCCEED (the completion + // reason lives inside the payload), then the SDK throws ParallelException + // AFTER the checkpoint. This matches the Python/JS/Java wire format. The + // workflow-level failure is asserted above via PollForCompletionAsync == + // FAILED and the ParallelException error type — the parent CONTEXT itself + // is NOT recorded as ContextFailed. + var parentSucceeded = events.FirstOrDefault(e => + e.EventType == EventType.ContextSucceeded && e.Name == "tolerance"); + Assert.NotNull(parentSucceeded); + Assert.DoesNotContain(events, e => e.EventType == EventType.ContextFailed && e.Name == "tolerance"); - Assert.NotNull(parentFailed); } } diff --git a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitForCallbackSubmitterFailsTest.cs b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitForCallbackSubmitterFailsTest.cs index e172a4ab0..e33fb62b0 100644 --- a/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitForCallbackSubmitterFailsTest.cs +++ b/Libraries/test/Amazon.Lambda.DurableExecution.IntegrationTests/WaitForCallbackSubmitterFailsTest.cs @@ -37,13 +37,16 @@ public async Task WaitForCallback_SubmitterThrows_SurfacesAsCallbackSubmitterExc var status = await deployment.PollForCompletionAsync(arn!, TimeSpan.FromSeconds(120)); Assert.Equal("FAILED", status, ignoreCase: true); - // The workflow surfaces CallbackSubmitterException — the SDK's wrapper - // type around the failed submitter step. Verify both the recorded - // ErrorType and that the original "submitter intentional failure" - // message survives in the error chain. + // The workflow surfaces CallbackSubmitterException to the user, but the + // terminal ErrorObject records the ORIGINAL error identity that the + // submitter threw. CallbackSubmitterException carries the failed step's + // ErrorType (the submitter's InvalidOperationException), and + // ErrorObject.FromException deliberately unwraps CallbackException to that + // underlying type — matching the Java/Python/JS SDKs and the unwrapping + // covered by ModelsTests.ErrorObject_FromException_UnwrapsCallbackException. var execution = await deployment.GetExecutionAsync(arn!); Assert.NotNull(execution.Error); - Assert.Equal(typeof(CallbackSubmitterException).FullName, execution.Error.ErrorType); + Assert.Equal(typeof(InvalidOperationException).FullName, execution.Error.ErrorType); // ErrorObject.FromException records the outer exception's Message; that // message should reference the submitter failure context. Be lenient // about exact wording since the SDK may prepend / wrap the inner. diff --git a/Libraries/test/IntegrationTests.Helpers/RetryHelper.cs b/Libraries/test/IntegrationTests.Helpers/RetryHelper.cs new file mode 100644 index 000000000..649b53716 --- /dev/null +++ b/Libraries/test/IntegrationTests.Helpers/RetryHelper.cs @@ -0,0 +1,57 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +using System; +using System.Threading.Tasks; + +namespace IntegrationTests.Helpers +{ + /// + /// Helpers for polling on eventually-consistent conditions in integration tests. + /// + public static class RetryHelper + { + /// + /// Polls until it returns true or elapses. + /// Useful for gating tests on resources that report ready (e.g. CloudFormation CREATE_COMPLETE) + /// before they are fully propagated and serving traffic (e.g. API Gateway stages/authorizers). + /// + /// The condition to evaluate. Returning true ends the wait. + /// Maximum total time to keep polling. + /// Delay between attempts. Defaults to 5 seconds. + /// true if the condition was met before the timeout; otherwise false. + public static async Task WaitForConditionAsync( + Func> condition, + TimeSpan timeout, + TimeSpan? pollInterval = null) + { + if (condition == null) throw new ArgumentNullException(nameof(condition)); + + var interval = pollInterval ?? TimeSpan.FromSeconds(5); + var deadline = DateTime.UtcNow + timeout; + + while (true) + { + try + { + if (await condition()) + { + return true; + } + } + catch + { + // Swallow transient errors (e.g. connection resets while the endpoint warms up) + // and keep polling until the deadline. + } + + if (DateTime.UtcNow >= deadline) + { + return false; + } + + await Task.Delay(interval); + } + } + } +} diff --git a/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/IntegrationTestContextFixture.cs b/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/IntegrationTestContextFixture.cs index f6a9138f4..06cba6a17 100644 --- a/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/IntegrationTestContextFixture.cs +++ b/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/IntegrationTestContextFixture.cs @@ -90,8 +90,42 @@ public async Task InitializeAsync() await LambdaHelper.WaitTillNotPending(LambdaFunctions.Where(x => x.Name != null).Select(x => x.Name!).ToList()); - // Wait an additional 10 seconds for any other eventual consistency state to finish up. - await Task.Delay(10000); + // CloudFormation reports CREATE_COMPLETE before API Gateway has fully propagated the deployed + // stage and the Lambda authorizer invoke permissions to the edge. During that window, requests + // on the authorizer "allow" path can transiently return 403. REST APIs (v1) settle slower than + // HTTP APIs (v2), so poll a known allow-path endpoint on each API until it serves traffic + // correctly rather than relying on a fixed sleep. + await WarmUpApisAsync(); + } + + /// + /// Polls a representative "allow path" endpoint on each deployed API until the custom authorizer + /// is fully wired and the request succeeds (or a 401 from the backend), confirming the API is + /// serving traffic before the test suite runs. + /// + private async Task WarmUpApisAsync() + { + var timeout = TimeSpan.FromMinutes(2); + var pollInterval = TimeSpan.FromSeconds(5); + + // A warmed-up authorizer returns a non-403 response on the allow path: either 200 (context + // present) or 401 (backend rejects missing context). A 403 means API Gateway could not yet + // invoke/attach the authorizer, so keep waiting. + async Task EndpointIsReady(string url) + { + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", "valid-token"); + var response = await HttpClient.SendAsync(request); + return response.StatusCode != System.Net.HttpStatusCode.Forbidden; + } + + var restReady = await RetryHelper.WaitForConditionAsync( + () => EndpointIsReady($"{RestApiUrl}/api/rest-user-info"), timeout, pollInterval); + Console.WriteLine($"[IntegrationTest] REST API warm-up {(restReady ? "succeeded" : "timed out")}."); + + var httpReady = await RetryHelper.WaitForConditionAsync( + () => EndpointIsReady($"{HttpApiUrl}/api/user-info"), timeout, pollInterval); + Console.WriteLine($"[IntegrationTest] HTTP API warm-up {(httpReady ? "succeeded" : "timed out")}."); } public async Task DisposeAsync() diff --git a/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/TestCustomAuthorizerApp.IntegrationTests.csproj b/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/TestCustomAuthorizerApp.IntegrationTests.csproj index 02540483e..bc3018c9c 100644 --- a/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/TestCustomAuthorizerApp.IntegrationTests.csproj +++ b/Libraries/test/TestCustomAuthorizerApp.IntegrationTests/TestCustomAuthorizerApp.IntegrationTests.csproj @@ -1,7 +1,7 @@  - net8.0;net10.0 + net10.0 enable enable Library diff --git a/Libraries/test/TestExecutableServerlessApp/aws-lambda-tools-defaults.json b/Libraries/test/TestExecutableServerlessApp/aws-lambda-tools-defaults.json index a5f516588..71140c33d 100644 --- a/Libraries/test/TestExecutableServerlessApp/aws-lambda-tools-defaults.json +++ b/Libraries/test/TestExecutableServerlessApp/aws-lambda-tools-defaults.json @@ -8,7 +8,7 @@ "profile": "default", "region": "us-west-2", "configuration": "Release", - "framework": "net6.0", + "framework": "net10.0", "s3-prefix": "TestServerlessApp/", "template": "serverless.template", "template-parameters": "", diff --git a/Libraries/test/TestServerlessApp.ALB/aws-lambda-tools-defaults.json b/Libraries/test/TestServerlessApp.ALB/aws-lambda-tools-defaults.json index 49464b51c..dad3e9b14 100644 --- a/Libraries/test/TestServerlessApp.ALB/aws-lambda-tools-defaults.json +++ b/Libraries/test/TestServerlessApp.ALB/aws-lambda-tools-defaults.json @@ -8,7 +8,7 @@ "profile": "", "region": "us-west-2", "configuration": "Release", - "framework": "net6.0", + "framework": "net10.0", "s3-bucket" : "test-alb-app-dce31eae", "stack-name" : "test-alb-app-dce31eae", "template": "serverless.template", diff --git a/buildtools/build.proj b/buildtools/build.proj index 0b80ec612..c2ea686e7 100644 --- a/buildtools/build.proj +++ b/buildtools/build.proj @@ -218,8 +218,12 @@ - - + + + + +