diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index a39a413..f835655 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -1,36 +1,84 @@ -# This is a basic workflow to help you get started with Actions - -name: CI - -# Controls when the action will run. Triggers the workflow on push or pull request -# events but only for the master branch -on: - push: - branches: [ master ] - tags: - - v* - pull_request: - branches: [ master ] - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel -jobs: - # This workflow contains a single job called "build" - publish: - # The type of runner that the job will run on - runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - # Runs a single command using the runners shell - - uses: actions/checkout@v2 - with: - fetch-depth: 0 - - name: Build And Push Influunt - uses: docker/build-push-action@v1.1.0 - with: - repository: eluki/influunt - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} - target: influunt - tag_with_ref: true - push: ${{ github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/') }} +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the action will run. Triggers the workflow on push or pull request +# events but only for the master branch +on: + push: + branches: [ master ] + tags: + - v* + pull_request: + branches: [ master ] + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + publish-webhost: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + # list of Docker images to use as base name for tags + images: eluki/influunt + # generate Docker tags based on the following events/attributes + tags: | + type=semver,pattern={{version}} + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=main,enable={{is_default_branch}} + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push Influunt Host + uses: docker/build-push-action@v6 + with: + context: . + target: influunt + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + publish-worker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + # list of Docker images to use as base name for tags + images: eluki/influunt-worker + # generate Docker tags based on the following events/attributes + tags: | + type=semver,pattern={{version}} + type=raw,value=latest,enable={{is_default_branch}} + type=raw,value=main,enable={{is_default_branch}} + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build and push Influunt Worker + uses: docker/build-push-action@v6 + with: + context: . + target: influunt + push: ${{ github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/') }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index d88866c..3ce1833 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -20,6 +20,20 @@ "sourceFileMap": { "/Views": "${workspaceFolder}/src/Influunt.Host/Views" } + }, + { + "name": "Influunt.Worker", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-Influunt.Worker", + "program": "${workspaceFolder}/src/Influunt.Worker/bin/Debug/net8/Influunt.Worker.dll", + "args": [], + "env": { + "DOTNET_ENVIRONMENT": "Development" + }, + "cwd": "${workspaceFolder}/src/Influunt.Worker", + "stopAtEntry": false, + "console": "internalConsole" } ] } \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json index 7e4bf2c..221c5f7 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -4,5 +4,8 @@ "Catched", "Influunt", "Skidbladnir" - ] + ], + "dotrush.roslyn.projectOrSolutionFiles": [ + "Influunt.sln" + ], } \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3b1c986..f107345 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,17 +1,29 @@ -{ - "version": "2.0.0", - "tasks": [ - { - "label": "build-Influunt.Host", - "command": "dotnet", - "type": "process", - "args": [ - "build", - "${workspaceFolder}/src/Influunt.Host/Influunt.Host.csproj", - "/property:GenerateFullPaths=true", - "/consoleloggerparameters:NoSummary" - ], - "problemMatcher": "$msCompile" - } - ] +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-Influunt.Host", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Influunt.Host/Influunt.Host.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + }, + { + "label": "build-Influunt.Worker", + "command": "dotnet", + "type": "process", + "args": [ + "build", + "${workspaceFolder}/src/Influunt.Worker/Influunt.Worker.csproj", + "/property:GenerateFullPaths=true", + "/consoleloggerparameters:NoSummary" + ], + "problemMatcher": "$msCompile" + } + ] } \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3181299 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,62 @@ +# Influunt Developer Guide + +## Project Overview +Influunt is a .NET 8 + Vue.js RSS aggregator with MongoDB storage and Redis caching. + +## Key Technologies +- Backend: .NET 8, ASP.NET Core +- Frontend: Vue.js 2.x with Vue CLI 3 +- Database: MongoDB +- Caching: Redis +- Authentication: Google OAuth2 and guest login + +## Project Structure +``` +src/ +├── Influunt.Host/ # Main .NET backend with Vue frontend +│ ├── ClientApp/ # Vue.js frontend +│ └── appsettings.json # Configuration +└── Influunt.Feed/ # Feed processing logic +└── Influunt.Storage/ # Data access layer +``` + +## Development Commands +### Backend (.NET) +- Build: `dotnet build` +- Run: `dotnet run` (from src/Influunt.Host/) +- Test: `dotnet test` (from tests/ directory) + +### Frontend (Vue.js) +- Install dependencies: `npm install` (in src/Influunt.Host/ClientApp/) +- Development server: `npm run serve` +- Production build: `npm run build` +- Lint: `npm run lint` + +## Important Notes +1. **Environment Setup**: + - Requires .NET 8 SDK + - Requires Node.js 16+ (due to legacy provider flag in package.json) + - Requires MongoDB and Redis instances + +2. **Configuration**: + - All settings in `appsettings.json` or environment variables + - Google Auth requires ClientId/ClientSecret + - Connection strings for MongoDB and Redis + +3. **Build Process**: + - .NET builds automatically restore npm packages for the frontend + - Vue build requires `NODE_OPTIONS=--openssl-legacy-provider` environment variable + +4. **Testing**: + - Tests are in the `tests/` directory + - Run with `dotnet test` from the test project directory + +5. **Docker Deployment**: + - Use provided docker-compose or docker run commands in README + - Requires MongoDB and Redis connection strings + - Google Auth credentials for authentication + +6. **Architecture**: + - Uses VueCliMiddleware for frontend integration + - Separated concerns: Host (web), Feed (processing), Storage (data) + - Background feed crawler enabled by default \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index 515a154..a7235c9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,6 +17,7 @@ RUN sed -i -e "s/0-develop<\/Version>/$(cat version | cut -c2- dotnet restore -s https://api.nuget.org/v3/index.json &&\ dotnet build --no-restore -c Release &&\ dotnet publish ./src/Influunt.Host/Influunt.Host.csproj -c Release -o /app --no-build &&\ + dotnet publish ./src/Influunt.Worker/Influunt.Worker.csproj -c Release -o /worker --no-build &&\ dotnet nuget locals http-cache --clear &&\ dotnet nuget locals temp --clear @@ -28,3 +29,9 @@ WORKDIR /influunt EXPOSE 80 ENV ASPNETCORE_URLS=http://*:80 ENTRYPOINT ["dotnet", "Influunt.Host.dll"] + +######## Influunt worker +FROM mcr.microsoft.com/dotnet/aspnet:8.0-bookworm-slim as worker +COPY --from=build /worker /influunt +WORKDIR /influunt +ENTRYPOINT ["dotnet", "Influunt.Worker.dll"] diff --git a/Influunt.sln b/Influunt.sln index b1b0565..d5745fd 100644 --- a/Influunt.sln +++ b/Influunt.sln @@ -11,32 +11,153 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Influunt.Storage", "src\Inf EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Influunt.Feed.Rss", "src\Influunt.Feed.Rss\Influunt.Feed.Rss.csproj", "{C8997ACE-506F-470F-8BD5-EC955BE522E0}" EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{827E0CD3-B72D-47B6-A68D-7590B98EB39B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Scheduler", "src\Influunt.Scheduler\Influunt.Scheduler.csproj", "{44AC6B8E-C694-48E7-95B6-5481EB122559}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Scheduler.Abstractions", "src\Influunt.Scheduler.Abstractions\Influunt.Scheduler.Abstractions.csproj", "{D065CA50-272A-4E29-8D75-3A373EBCB98B}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{0AB3BF05-4346-4AA6-1389-037BE0695223}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Scheduler.Tests", "tests\Influunt.Scheduler.Tests\Influunt.Scheduler.Tests.csproj", "{E9EA202F-F5BF-446B-A7BB-886643AE20C6}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Worker", "src\Influunt.Worker\Influunt.Worker.csproj", "{5DF597D9-BDF2-472A-917B-F82E0D42EC6C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Influunt.Feed.Tests", "tests\Influunt.Feed.Tests\Influunt.Feed.Tests.csproj", "{513FAC6E-AD73-4329-AB6E-272311D9FF6B}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {D5232164-9233-4B32-8022-75799161F113}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D5232164-9233-4B32-8022-75799161F113}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Debug|x64.ActiveCfg = Debug|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Debug|x64.Build.0 = Debug|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Debug|x86.ActiveCfg = Debug|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Debug|x86.Build.0 = Debug|Any CPU {D5232164-9233-4B32-8022-75799161F113}.Release|Any CPU.ActiveCfg = Release|Any CPU {D5232164-9233-4B32-8022-75799161F113}.Release|Any CPU.Build.0 = Release|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Release|x64.ActiveCfg = Release|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Release|x64.Build.0 = Release|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Release|x86.ActiveCfg = Release|Any CPU + {D5232164-9233-4B32-8022-75799161F113}.Release|x86.Build.0 = Release|Any CPU {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|Any CPU.Build.0 = Debug|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|x64.ActiveCfg = Debug|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|x64.Build.0 = Debug|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|x86.ActiveCfg = Debug|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Debug|x86.Build.0 = Debug|Any CPU {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|Any CPU.ActiveCfg = Release|Any CPU {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|Any CPU.Build.0 = Release|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|x64.ActiveCfg = Release|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|x64.Build.0 = Release|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|x86.ActiveCfg = Release|Any CPU + {82E2087D-00F6-47A4-90DE-47BFAF40DF39}.Release|x86.Build.0 = Release|Any CPU {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|x64.ActiveCfg = Debug|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|x64.Build.0 = Debug|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|x86.ActiveCfg = Debug|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Debug|x86.Build.0 = Debug|Any CPU {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|Any CPU.ActiveCfg = Release|Any CPU {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|Any CPU.Build.0 = Release|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|x64.ActiveCfg = Release|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|x64.Build.0 = Release|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|x86.ActiveCfg = Release|Any CPU + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C}.Release|x86.Build.0 = Release|Any CPU {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|x64.ActiveCfg = Debug|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|x64.Build.0 = Debug|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|x86.ActiveCfg = Debug|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Debug|x86.Build.0 = Debug|Any CPU {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|Any CPU.ActiveCfg = Release|Any CPU {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|Any CPU.Build.0 = Release|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|x64.ActiveCfg = Release|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|x64.Build.0 = Release|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|x86.ActiveCfg = Release|Any CPU + {C8997ACE-506F-470F-8BD5-EC955BE522E0}.Release|x86.Build.0 = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|Any CPU.Build.0 = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|x64.ActiveCfg = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|x64.Build.0 = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|x86.ActiveCfg = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Debug|x86.Build.0 = Debug|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|Any CPU.ActiveCfg = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|Any CPU.Build.0 = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|x64.ActiveCfg = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|x64.Build.0 = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|x86.ActiveCfg = Release|Any CPU + {44AC6B8E-C694-48E7-95B6-5481EB122559}.Release|x86.Build.0 = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|x64.ActiveCfg = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|x64.Build.0 = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|x86.ActiveCfg = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Debug|x86.Build.0 = Debug|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|Any CPU.Build.0 = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|x64.ActiveCfg = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|x64.Build.0 = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|x86.ActiveCfg = Release|Any CPU + {D065CA50-272A-4E29-8D75-3A373EBCB98B}.Release|x86.Build.0 = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|x64.ActiveCfg = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|x64.Build.0 = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|x86.ActiveCfg = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Debug|x86.Build.0 = Debug|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|Any CPU.Build.0 = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|x64.ActiveCfg = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|x64.Build.0 = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|x86.ActiveCfg = Release|Any CPU + {E9EA202F-F5BF-446B-A7BB-886643AE20C6}.Release|x86.Build.0 = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|x64.ActiveCfg = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|x64.Build.0 = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|x86.ActiveCfg = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Debug|x86.Build.0 = Debug|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|Any CPU.Build.0 = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|x64.ActiveCfg = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|x64.Build.0 = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|x86.ActiveCfg = Release|Any CPU + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C}.Release|x86.Build.0 = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|x64.ActiveCfg = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|x64.Build.0 = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|x86.ActiveCfg = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Debug|x86.Build.0 = Debug|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|Any CPU.Build.0 = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|x64.ActiveCfg = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|x64.Build.0 = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|x86.ActiveCfg = Release|Any CPU + {513FAC6E-AD73-4329-AB6E-272311D9FF6B}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D5232164-9233-4B32-8022-75799161F113} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {82E2087D-00F6-47A4-90DE-47BFAF40DF39} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {9F87FB47-0B6A-435A-BC52-5AE6AC25245C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {C8997ACE-506F-470F-8BD5-EC955BE522E0} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {44AC6B8E-C694-48E7-95B6-5481EB122559} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {D065CA50-272A-4E29-8D75-3A373EBCB98B} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {E9EA202F-F5BF-446B-A7BB-886643AE20C6} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + {5DF597D9-BDF2-472A-917B-F82E0D42EC6C} = {827E0CD3-B72D-47B6-A68D-7590B98EB39B} + {513FAC6E-AD73-4329-AB6E-272311D9FF6B} = {0AB3BF05-4346-4AA6-1389-037BE0695223} + EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {FDF77C48-6495-4B15-9513-34DB5A584086} EndGlobalSection diff --git a/README.md b/README.md index 929287b..4a07ca3 100644 --- a/README.md +++ b/README.md @@ -54,11 +54,26 @@ Influunt use MongoDB as persistent data storage and Redis for faster distributed ### Feed -* `FeedCrawler:Enabled` - enable background channels fetch (default: `true`). _In the future, can be disabled in api, and enabled in external worker_ -* `FeedCrawler:FetchInterval` - interval between fetching news from channels (default: `00:30:00` (30min)) +* `FeedCrawler:Enabled` - enable job host inside web host (default: `true`). _It may be disabled if you run Worker service_ * `FeedCrawler:LastActivityDaysAgo` - Minimal user last activity date, for fetch news. If user don't use service more then n days, don't fetch news from user channel (default 93 days (~3 month)) -## Screenshots +### Scheduler + +Scheduler instances coordinate through MongoDB. `AllowConcurrentExecution = false` serializes a job type across all instances; `MaxConcurrentJobs` limits each instance. `ScanBatchSize` controls cursor batches without limiting how far a scan searches past busy triggers. The lock timeout must exceed the heartbeat interval. + +Each trigger generation and scheduled time has a persistent execution record. Expired `Pending` and `Running` executions are recovered with a new ownership token. Completion and heartbeat updates check that token so a previous owner cannot finish a replacement attempt. + +Successful and failed occurrences both consume their trigger: one-time triggers are removed, and recurring triggers advance to the next cron occurrence after completion. Missed cron occurrences are skipped. Exceptions do not request automatic retries; interrupted executions remain recoverable. Jobs should tolerate repeated side effects after a process crash or lease loss. + +On upgrade, stop older scheduler instances before starting this version. Startup creates the unique `TriggerOccurrence` index and removes the former unique index on `JobExecution.TriggerId`. Existing execution documents are preserved and adopted when recovered; subsequent occurrences retain their own records. MongoDB transactions or a replica set are not required. + +Shutdown cancels scanning, waits for in-flight scans and heartbeat writes, and cancels running jobs within the host's shutdown deadline. RSS jobs propagate cancellation through scheduling, channel loading, the concurrency limiter, HTTP, cache and storage operations. Feed download and storage errors reach the scheduler; the crawler continues scheduling other users and reports any enqueue failures together. + +### Tests + +Run fast tests with `dotnet test tests/Influunt.Scheduler.Tests --filter Category=Unit` and `dotnet test tests/Influunt.Feed.Tests`. Run MongoDB tests with `dotnet test tests/Influunt.Scheduler.Tests --filter Category=Integration` (Docker required). Add `--collect:"XPlat Code Coverage"` to collect coverage. Scheduler time boundaries use an injected `TimeProvider`; MongoDB failures and concurrent scheduler instances are covered separately. + +## Screenshots | | | | ------------------------------------------------------------------------------------------ | :---------------------------------------------------------------------------------------: | | ![Welcome](https://i.postimg.cc/TPkkZRgs/2020-11-28-12-37-08-localhost-06e95107c6d2.png) | ![Feed](https://i.postimg.cc/nzg3h0dY/2020-11-28-12-37-48-localhost-2ef2932fe865.png) | @@ -66,7 +81,7 @@ Influunt use MongoDB as persistent data storage and Redis for faster distributed ## Using in docker -**Docker compose file for swarm mode:** +**Docker compose file for swarm mode (with worker):** ```Dockerfile version: '3.8' @@ -78,6 +93,7 @@ services: - "ConnectionStrings:Redis:ConnectionString=[Redis address]" # optional redis-based distributed cache connection - "Authentication:Google:ClientSecret=[GOOGLE CLIENT SECRET]" - "Authentication:Google:ClientId=[GOOGLE CLIENT ID]" + - "FeedCrawler:Enabled=false" ports: - target: 80 published: 30002 @@ -90,6 +106,17 @@ services: options: max-size: "3m" max-file: "3" + host: + image: eluki/influunt-worker + environment: + - "ConnectionStrings:Mongo:ConnectionString=[MONGO DB CONNECTION STRING]" + deploy: + replicas: 1 + logging: + driver: "json-file" + options: + max-size: "3m" + max-file: "3" ``` Run stack: ```bash diff --git a/src/Influunt.Feed.Rss/Extensions.cs b/src/Influunt.Feed.Rss/Extensions.cs index 6f42de7..fff8ecd 100644 --- a/src/Influunt.Feed.Rss/Extensions.cs +++ b/src/Influunt.Feed.Rss/Extensions.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text; using System.Threading.Tasks; +using System.Threading; using System.Xml.Serialization; using Influunt.Feed.Entity; using Microsoft.Extensions.Caching.Distributed; @@ -13,19 +14,19 @@ namespace Influunt.Feed.Rss; public static class Extensions { - public static async Task GetAsync(this IDistributedCache cache, string key) + public static async Task GetAsync(this IDistributedCache cache, string key, CancellationToken cancellationToken = default) { - var cacheValue = await cache.GetStringAsync(key); + var cacheValue = await cache.GetStringAsync(key, cancellationToken); if (string.IsNullOrWhiteSpace(cacheValue)) return default; return JsonConvert.DeserializeObject(cacheValue); } public static Task SetAsync(this IDistributedCache cache, string key, object entry, - DistributedCacheEntryOptions options) + DistributedCacheEntryOptions options, CancellationToken cancellationToken = default) { var serializedEntry = JsonConvert.SerializeObject(entry); - return cache.SetAsync(key, Encoding.UTF8.GetBytes(serializedEntry), options); + return cache.SetAsync(key, Encoding.UTF8.GetBytes(serializedEntry), options, cancellationToken); } public static List AsList(this IEnumerable source) @@ -83,4 +84,4 @@ public static List FeedFromAtomRss(this string xml) return item.NormalizeDescription(); }).ToList(); } -} \ No newline at end of file +} diff --git a/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj b/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj index f4c5ec1..366126c 100644 --- a/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj +++ b/src/Influunt.Feed.Rss/Influunt.Feed.Rss.csproj @@ -14,7 +14,7 @@ - + @@ -22,5 +22,8 @@ + + + - \ No newline at end of file + diff --git a/src/Influunt.Feed.Rss/RssClient.cs b/src/Influunt.Feed.Rss/RssClient.cs index 520ca0a..08ebf5a 100644 --- a/src/Influunt.Feed.Rss/RssClient.cs +++ b/src/Influunt.Feed.Rss/RssClient.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; +using System.Threading; using Influunt.Feed.Entity; using Microsoft.Extensions.Logging; @@ -18,26 +19,32 @@ public RssClient(HttpClient httpClient, ILogger logger) _logger = logger; } - public async Task> GetFeed(FeedChannel channel) + public async Task> GetFeed(FeedChannel channel, CancellationToken cancellationToken = default) { try { - var request = new HttpRequestMessage(HttpMethod.Get, channel.Url); + cancellationToken.ThrowIfCancellationRequested(); + using var request = new HttpRequestMessage(HttpMethod.Get, channel.Url); request.Headers.Add("user-agent", UserAgents.Generate()); - using (var result = await _httpClient.SendAsync(request)) + using (var result = await _httpClient.SendAsync(request, cancellationToken)) { - var xmlRss = await result.Content.ReadAsStringAsync(); + result.EnsureSuccessStatusCode(); + var xmlRss = await result.Content.ReadAsStringAsync(cancellationToken); return xmlRss.IsAtomRss() ? xmlRss.FeedFromAtomRss() : xmlRss.FeedFromRss(); } } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } catch (Exception e) { _logger.LogError( "Can not get rss feed from \nchannel: {channelName}\nurl: {channelUrl}\n with error: {message} ", channel.Name, channel.Url, e.Message); - return Array.Empty(); + throw; } } -} \ No newline at end of file +} diff --git a/src/Influunt.Feed.Rss/RssFeedSourceProvider.cs b/src/Influunt.Feed.Rss/RssFeedSourceProvider.cs index 32f6c13..4e9a790 100644 --- a/src/Influunt.Feed.Rss/RssFeedSourceProvider.cs +++ b/src/Influunt.Feed.Rss/RssFeedSourceProvider.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Diagnostics; using System.Threading.Tasks; +using System.Threading; using Influunt.Feed.Entity; namespace Influunt.Feed.Rss; @@ -22,10 +23,11 @@ public RssFeedSourceProvider(RssClient rssClient, IDistributedCache distributedC _logger = logger; } - public async Task> GetRemoteFeed(FeedChannel channel) + public async Task> GetRemoteFeed(FeedChannel channel, CancellationToken cancellationToken = default) { var sw = Stopwatch.StartNew(); - var remoteFeed = await GetFeedFromChannelCached(channel); + cancellationToken.ThrowIfCancellationRequested(); + var remoteFeed = await GetFeedFromChannelCached(channel, cancellationToken); _logger.LogDebug($"Elapsed time for getting channel ({channel.Id}) feed: {sw.Elapsed.TotalMilliseconds}ms"); return remoteFeed; } @@ -38,18 +40,18 @@ public void Dispose() GC.SuppressFinalize(this); } - private async Task> GetFeedFromChannelCached(FeedChannel channel) + private async Task> GetFeedFromChannelCached(FeedChannel channel, CancellationToken cancellationToken) { - var channelFeed = await _distributedCache.GetAsync>($"channel_url_{channel.Url}"); + var channelFeed = await _distributedCache.GetAsync>($"channel_url_{channel.Url}", cancellationToken); if (channelFeed is not null && channelFeed.Count != 0) return channelFeed; - channelFeed = (await _rssClient.GetFeed(channel)).AsList(); + channelFeed = (await _rssClient.GetFeed(channel, cancellationToken)).AsList(); if (channelFeed.Count != 0) await _distributedCache.SetAsync($"channel_url_{channel.Url}", channelFeed, new DistributedCacheEntryOptions() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(30) - }); + }, cancellationToken); return channelFeed; } -} \ No newline at end of file +} diff --git a/src/Influunt.Feed/Crawler/CrawlerOptions.cs b/src/Influunt.Feed/Crawler/CrawlerOptions.cs index 3ca4f80..d2f5f57 100644 --- a/src/Influunt.Feed/Crawler/CrawlerOptions.cs +++ b/src/Influunt.Feed/Crawler/CrawlerOptions.cs @@ -1,9 +1,6 @@ -using System; - namespace Influunt.Feed.Crawler; public class CrawlerOptions { - public TimeSpan FetchInterval { get; set; } = TimeSpan.FromMinutes(30); public int LastActivityDaysAgo { get; set; } = 31*3; // 3 Month default } \ No newline at end of file diff --git a/src/Influunt.Feed/Crawler/FeedCrawlerBackgroundWorker.cs b/src/Influunt.Feed/Crawler/FeedCrawlerBackgroundWorker.cs deleted file mode 100644 index 68a4407..0000000 --- a/src/Influunt.Feed/Crawler/FeedCrawlerBackgroundWorker.cs +++ /dev/null @@ -1,95 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Influunt.Feed.Entity; -using Microsoft.Extensions.Hosting; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Skidbladnir.Utility.Common; - -namespace Influunt.Feed.Crawler; - -public class FeedCrawlerBackgroundWorker : BackgroundService -{ - private readonly IEnumerable _feedSourceProviders; - private readonly IFeedService _feedService; - private readonly IUserService _userService; - private readonly IChannelService _channelService; - private readonly CrawlerOptions _options; - private readonly ILogger _logger; - - public FeedCrawlerBackgroundWorker(IEnumerable feedSourceProviders, - IFeedService feedService, IUserService userService, - IOptions options, - IChannelService channelService, - ILogger logger) - { - _feedSourceProviders = feedSourceProviders; - _feedService = feedService; - _userService = userService; - _channelService = channelService; - _options = options.Value; - _logger = logger; - } - - protected override async Task ExecuteAsync(CancellationToken stoppingToken) - { - _logger.LogInformation("Feed Crawler Background Worker running."); - - - while (!stoppingToken.IsCancellationRequested) - { - try - { - await InnerExecute(stoppingToken); - } - catch (Exception e) - { - if (e is OperationCanceledException) - break; - _logger.LogError(e, "Error in Feed Crawler Background Worker when fetch feeds."); - } - await Task.Delay(_options.FetchInterval, stoppingToken); - } - _logger.LogInformation("Feed Crawler Background Worker is stopping."); - - } - - private async Task InnerExecute(CancellationToken token) - { - var minimumLastActivityDate = DateTime.UtcNow - TimeSpan.FromDays(_options.LastActivityDaysAgo); - var users = await _userService.GetUsers(); - token.ThrowIfCancellationRequested(); - foreach (var user in users.Where(x => x.LastActivity > minimumLastActivityDate)) - { - token.ThrowIfCancellationRequested(); - var channels = await Try.DoAsync(() => _channelService.GetUserChannels(user)); - if (channels is null) - continue; - try - { - var fetchTasks = channels.Select(x => FetchFeedFromChannel(x, user, token)); - await Task.WhenAll(fetchTasks); - } - catch (Exception e) - { - if (e is OperationCanceledException) - throw; - _logger.LogError(e, $"Can't fetch feed for user {user.Id}"); - } - } - } - - private async Task FetchFeedFromChannel(FeedChannel channel, User user, CancellationToken token) - { - token.ThrowIfCancellationRequested(); - var remoteFeedProvider = _feedSourceProviders.FirstOrDefault(x => x.CanProcessChannel(channel)); - if (remoteFeedProvider is null) - return; - var remoteFeed = await remoteFeedProvider.GetRemoteFeed(channel); - var count = await _feedService.TryAddToFeed(user, remoteFeed, channel); - _logger.LogInformation($"For user {user.Id} added {count} posts from channel {channel.Id}"); - } -} \ No newline at end of file diff --git a/src/Influunt.Feed/Crawler/FeedCrawlerJob.cs b/src/Influunt.Feed/Crawler/FeedCrawlerJob.cs new file mode 100644 index 0000000..70cbdd3 --- /dev/null +++ b/src/Influunt.Feed/Crawler/FeedCrawlerJob.cs @@ -0,0 +1,68 @@ +using System; +using System.Linq; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Influunt.Scheduler.Abstractions.Models; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Influunt.Feed.Crawler; + +public class FeedCrawlerJob : IJob +{ + private readonly IUserService _userService; + private readonly IScheduler _scheduler; + private readonly ILogger _logger; + private readonly CrawlerOptions _options; + private readonly TimeProvider _timeProvider; + + public FeedCrawlerJob(IOptions options, + IUserService userService, + IScheduler scheduler, + ILogger logger, TimeProvider timeProvider = null) + { + _options = options.Value; + _userService = userService; + _scheduler = scheduler; + _logger = logger; + _timeProvider = timeProvider ?? TimeProvider.System; + } + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Feed Crawler job started"); + cancellationToken.ThrowIfCancellationRequested(); + var minimumLastActivityDate = _timeProvider.GetUtcNow().UtcDateTime - TimeSpan.FromDays(_options.LastActivityDaysAgo); + var users = await _userService.GetUsers(cancellationToken); + var errors = new List(); + + foreach (var user in users.Where(x => x.LastActivity > minimumLastActivityDate)) + { + cancellationToken.ThrowIfCancellationRequested(); + try + { + var triggerId = await _scheduler.ScheduleOneTimeJobAsync(new UserFeedChannelUpdateJobSettings() + { + User = user + }, cancellationToken: cancellationToken); + _logger.LogInformation("Feed channel update job enqueued for user {email} with trigger id {triggerId}", user.Email, triggerId); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception ex) + { + errors.Add(ex); + _logger.LogError(ex, "Failed to schedule feed update for user {email}", user.Email); + } + } + cancellationToken.ThrowIfCancellationRequested(); + if (errors.Count > 0) + throw new AggregateException("Failed to schedule one or more feed updates.", errors); + _logger.LogInformation("Feed Crawler job ended"); + } + +} diff --git a/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs new file mode 100644 index 0000000..2a93d06 --- /dev/null +++ b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJob.cs @@ -0,0 +1,87 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Influunt.Feed.Entity; +using Influunt.Scheduler.Abstractions.Models; +using Microsoft.Extensions.Logging; + +namespace Influunt.Feed.Crawler; + +public class UserFeedChannelUpdateJob : IJob +{ + private readonly IEnumerable _feedSourceProviders; + private readonly IFeedService _feedService; + private readonly IChannelService _channelService; + private readonly ILogger _logger; + + public UserFeedChannelUpdateJob(IEnumerable feedSourceProviders, + IFeedService feedService, + IChannelService channelService, + ILogger logger) + { + _feedSourceProviders = feedSourceProviders; + _feedService = feedService; + _channelService = channelService; + _logger = logger; + } + + public async Task ExecuteAsync(UserFeedChannelUpdateJobSettings settings, CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (settings?.User is null) + { + _logger.LogError("User is null"); + return; + } + using var scope = _logger.BeginScope("Update feed channels job for user {email}", settings.User.Email); + var channels = await _channelService.GetUserChannels(settings.User, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + if (channels is null) + return; + + var throttler = new SemaphoreSlim(5, 5); + try + { + var fetchTasks = channels.Select(x => FetchFeedFromChannel(x, settings.User, throttler, cancellationToken)); + await Task.WhenAll(fetchTasks); + cancellationToken.ThrowIfCancellationRequested(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + throw; + } + catch (Exception e) + { + _logger.LogError(e, $"Can't fetch feed"); + throw; + } + finally + { + throttler.Dispose(); + } + } + + + private async Task FetchFeedFromChannel(FeedChannel channel, User user, SemaphoreSlim throttler, CancellationToken cancellationToken) + { + await throttler.WaitAsync(cancellationToken); + try + { + cancellationToken.ThrowIfCancellationRequested(); + var remoteFeedProvider = _feedSourceProviders.FirstOrDefault(x => x.CanProcessChannel(channel)); + if (remoteFeedProvider is null) + return; + var remoteFeed = await remoteFeedProvider.GetRemoteFeed(channel, cancellationToken); + cancellationToken.ThrowIfCancellationRequested(); + var count = await _feedService.TryAddToFeed(user, remoteFeed, channel, cancellationToken); + _logger.LogInformation("Added {count} posts from channel {channelId} to {email} feed", count, channel.Id, user.Email); + } + finally + { + throttler.Release(); + } + } + +} diff --git a/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJobSettings.cs b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJobSettings.cs new file mode 100644 index 0000000..c7e60ea --- /dev/null +++ b/src/Influunt.Feed/Crawler/UserFeedChannelUpdateJobSettings.cs @@ -0,0 +1,8 @@ +using Influunt.Feed.Entity; + +namespace Influunt.Feed.Crawler; + +public class UserFeedChannelUpdateJobSettings +{ + public User User { get; set; } +} diff --git a/src/Influunt.Feed/Extensions/ServicesExtensions.cs b/src/Influunt.Feed/Extensions/ServicesExtensions.cs new file mode 100644 index 0000000..ef9549c --- /dev/null +++ b/src/Influunt.Feed/Extensions/ServicesExtensions.cs @@ -0,0 +1,15 @@ +using System.Threading.Tasks; +using Influunt.Feed.Crawler; +using Microsoft.Extensions.DependencyInjection; + +namespace Influunt.Feed.Extensions; + +public static class ServicesExtensions +{ + public static IServiceCollection AddFeedJobs(this IServiceCollection services) + { + return services.AddScoped() + .AddScoped() + .AddHostedService(); + } +} diff --git a/src/Influunt.Feed/FeedJobsRegistrationService.cs b/src/Influunt.Feed/FeedJobsRegistrationService.cs new file mode 100644 index 0000000..359d855 --- /dev/null +++ b/src/Influunt.Feed/FeedJobsRegistrationService.cs @@ -0,0 +1,29 @@ +using System.Threading; +using System.Threading.Tasks; +using Influunt.Feed.Crawler; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.Hosting; + +namespace Influunt.Feed; + +public class FeedJobsRegistrationService : BackgroundService +{ + private const string CrawlerJobTriggerId = "RegularFeedCrawlerJob"; + private readonly IScheduler _scheduler; + + public FeedJobsRegistrationService(IScheduler scheduler) + { + _scheduler = scheduler; + + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + await _scheduler.RegisterJobAsync("Users feeds updating job", "The task starts updating RSS feeds for each active user"); + await _scheduler.RegisterJobAsync("User feed updating job", "The task receives new posts from the user's RSS feeds and adds them to the news feed"); + + await _scheduler.UnScheduleJobAsync(CrawlerJobTriggerId); + await _scheduler.ScheduleRecurringJobAsync("*/30 * * * *", CrawlerJobTriggerId); + } + +} diff --git a/src/Influunt.Feed/IChannelService.cs b/src/Influunt.Feed/IChannelService.cs index fa97684..6678bf9 100644 --- a/src/Influunt.Feed/IChannelService.cs +++ b/src/Influunt.Feed/IChannelService.cs @@ -1,6 +1,7 @@ using Influunt.Feed.Entity; using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; namespace Influunt.Feed; @@ -44,5 +45,5 @@ public interface IChannelService /// /// /// - Task> GetUserChannels(User user); -} \ No newline at end of file + Task> GetUserChannels(User user, CancellationToken cancellationToken = default); +} diff --git a/src/Influunt.Feed/IFeedService.cs b/src/Influunt.Feed/IFeedService.cs index 1229f71..1aeeaed 100644 --- a/src/Influunt.Feed/IFeedService.cs +++ b/src/Influunt.Feed/IFeedService.cs @@ -1,6 +1,7 @@ using Influunt.Feed.Entity; using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; namespace Influunt.Feed; @@ -42,5 +43,5 @@ public interface IFeedService /// /// /// - Task TryAddToFeed(User user, IEnumerable newFeedItems, FeedChannel channel = null); -} \ No newline at end of file + Task TryAddToFeed(User user, IEnumerable newFeedItems, FeedChannel channel = null, CancellationToken cancellationToken = default); +} diff --git a/src/Influunt.Feed/IFeedSourceProvider.cs b/src/Influunt.Feed/IFeedSourceProvider.cs index de30b38..d932eb7 100644 --- a/src/Influunt.Feed/IFeedSourceProvider.cs +++ b/src/Influunt.Feed/IFeedSourceProvider.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; using Influunt.Feed.Entity; namespace Influunt.Feed; @@ -12,7 +13,7 @@ public interface IFeedSourceProvider /// /// /// - Task> GetRemoteFeed(FeedChannel channel); + Task> GetRemoteFeed(FeedChannel channel, CancellationToken cancellationToken = default); /// /// Can this provider process this channel @@ -20,4 +21,4 @@ public interface IFeedSourceProvider /// /// bool CanProcessChannel(FeedChannel channel); -} \ No newline at end of file +} diff --git a/src/Influunt.Feed/IUserService.cs b/src/Influunt.Feed/IUserService.cs index 446fc76..de22915 100644 --- a/src/Influunt.Feed/IUserService.cs +++ b/src/Influunt.Feed/IUserService.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; using Influunt.Feed.Entity; namespace Influunt.Feed; @@ -13,7 +14,7 @@ public interface IUserService /// Получение списка пользователей /// /// - Task> GetUsers(); + Task> GetUsers(CancellationToken cancellationToken = default); /// /// Получение текущего пользователя @@ -55,4 +56,4 @@ public interface IUserService /// /// Task Remove(User user); -} \ No newline at end of file +} diff --git a/src/Influunt.Feed/Influunt.Feed.csproj b/src/Influunt.Feed/Influunt.Feed.csproj index 9d4e64d..385e87d 100644 --- a/src/Influunt.Feed/Influunt.Feed.csproj +++ b/src/Influunt.Feed/Influunt.Feed.csproj @@ -1,19 +1,21 @@ - - - - net8 - Klabukov Erik - Influunt - Influunt - Simple Rss Agregator - Klabukov Erik - false - - - - - - - - - - + + + net8 + Klabukov Erik + Influunt + Influunt - Simple Rss Agregator + Klabukov Erik + false + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Influunt.Host/Controllers/ChannelController.cs b/src/Influunt.Host/Controllers/ChannelController.cs index 3a9cdfc..418c7af 100644 --- a/src/Influunt.Host/Controllers/ChannelController.cs +++ b/src/Influunt.Host/Controllers/ChannelController.cs @@ -1,4 +1,5 @@ using System; +using System.Threading; using Influunt.Feed; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -6,6 +7,9 @@ using System.Net; using System.Threading.Tasks; using Influunt.Host.ViewModels; +using Influunt.Scheduler.Abstractions.Services; +using Influunt.Feed.Entity; +using Influunt.Feed.Crawler; namespace Influunt.Host.Controllers; @@ -20,15 +24,18 @@ public class ChannelController : ControllerBase private readonly IUserService _userService; private readonly IChannelService _channelService; private readonly IFeedService _feedService; + private readonly IScheduler _scheduler; /// public ChannelController(IUserService userService, IChannelService channelService, - IFeedService feedService) + IFeedService feedService, + IScheduler scheduler) { _userService = userService; _channelService = channelService; _feedService = feedService; + _scheduler = scheduler; } /// @@ -58,7 +65,7 @@ public async Task Get(string id) if (channel.UserId.Equals(user.Id, StringComparison.OrdinalIgnoreCase)) return channel.ToModel(); - Response.StatusCode = (int) HttpStatusCode.Forbidden; + Response.StatusCode = (int)HttpStatusCode.Forbidden; return null; } @@ -113,12 +120,28 @@ public async Task Delete(string id) { var user = await _userService.GetCurrentUser(); var channel = await _channelService.Get(id); - if (!channel.UserId.Equals(user.Id, StringComparison.OrdinalIgnoreCase)) + if (!channel.UserId.Equals(user.Id, StringComparison.OrdinalIgnoreCase)) return Forbid(); await _channelService.Remove(user, channel); -#pragma warning disable 4014 - _feedService.RemoveFeedByChannel(user, channel); -#pragma warning restore 4014 + _ = Task.Factory.StartNew( + () => _feedService.RemoveFeedByChannel(user, channel), + CancellationToken.None, + TaskCreationOptions.DenyChildAttach, + TaskScheduler.Default + ).Unwrap(); return Ok(); } + + private async Task ScheduleFeedUpdateJob(User user) + { + var triggerId = $"user_{user.Id}_manual_feed_update"; + await _scheduler.UnScheduleJobAsync(triggerId); + await _scheduler.ScheduleOneTimeJobAsync( + new UserFeedChannelUpdateJobSettings() + { + User = user + }, + DateTime.UtcNow + TimeSpan.FromMinutes(10), + triggerId); + } } \ No newline at end of file diff --git a/src/Influunt.Host/Influunt.Host.csproj b/src/Influunt.Host/Influunt.Host.csproj index c04b05d..9acdf12 100644 --- a/src/Influunt.Host/Influunt.Host.csproj +++ b/src/Influunt.Host/Influunt.Host.csproj @@ -24,8 +24,7 @@ - - + @@ -34,16 +33,17 @@ - - - - - + + + + + + diff --git a/src/Influunt.Host/Program.cs b/src/Influunt.Host/Program.cs index a5a52d7..5c1f85d 100644 --- a/src/Influunt.Host/Program.cs +++ b/src/Influunt.Host/Program.cs @@ -1,6 +1,8 @@ +using System; using Influunt.Feed.Crawler; using Influunt.Host; using Influunt.Host.Configurations; +using Influunt.Scheduler; using Influunt.Storage; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; @@ -13,10 +15,17 @@ var builder = WebApplication.CreateBuilder(args); builder.Services.AddOptions(); +Action schedulerConfigureAction = (c) => +{ + builder.Configuration.GetSection("Scheduler").Bind(c); + c.ConnectionString = builder.Configuration.GetSection("ConnectionStrings:Mongo").GetValue("ConnectionString"); +}; builder.Services.Configure(builder.Configuration.GetSection("FeedCrawler")); var crawlerEnabled = builder.Configuration.GetSection("FeedCrawler:Enabled").Get(); if (crawlerEnabled) - builder.Services.AddHostedService(); + builder.Services.AddJobSchedulerHost(schedulerConfigureAction); +else + builder.Services.AddJobScheduler(schedulerConfigureAction); builder.Services.AddSkidbladnirModules(configuration => { diff --git a/src/Influunt.Host/StartupModule.cs b/src/Influunt.Host/StartupModule.cs index 95d56c1..96d3931 100644 --- a/src/Influunt.Host/StartupModule.cs +++ b/src/Influunt.Host/StartupModule.cs @@ -1,4 +1,5 @@ using System; +using Influunt.Feed.Extensions; using Influunt.Feed.Rss; using Influunt.Host.Configurations; using Influunt.Host.Services; @@ -23,6 +24,7 @@ public override void Configure(IServiceCollection services) .PersistKeysToMongoDb(Configuration.AppConfiguration["ConnectionStrings:Mongo:ConnectionString"]); ConfigureDistributedCache(services); services.TryAddSingleton(); + services.AddFeedJobs(); } private void ConfigureDistributedCache(IServiceCollection services) diff --git a/src/Influunt.Host/WebModule.cs b/src/Influunt.Host/WebModule.cs index 1db7180..26edf98 100644 --- a/src/Influunt.Host/WebModule.cs +++ b/src/Influunt.Host/WebModule.cs @@ -7,7 +7,7 @@ using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.Extensions.DependencyInjection; -using Microsoft.OpenApi.Models; +using Microsoft.OpenApi; using Skidbladnir.Modules; namespace Influunt.Host; diff --git a/src/Influunt.Host/appsettings.Development.json b/src/Influunt.Host/appsettings.Development.json index 3f90f31..827a2f8 100644 --- a/src/Influunt.Host/appsettings.Development.json +++ b/src/Influunt.Host/appsettings.Development.json @@ -1,10 +1,7 @@ -{ - "Logging": { - "LogLevel": { - "Default": "Debug" - } - }, - "FeedService": { - "FeedUpdateCron": "* * * * *" - } -} +{ + "Logging": { + "LogLevel": { + "Default": "Debug" + } + } +} diff --git a/src/Influunt.Host/appsettings.json b/src/Influunt.Host/appsettings.json index ea6dbdd..fe4bd46 100644 --- a/src/Influunt.Host/appsettings.json +++ b/src/Influunt.Host/appsettings.json @@ -23,5 +23,11 @@ }, "FeedCrawler": { "Enabled": true - } + }, + "Scheduler":{ + "ScanIntervalSeconds": 30, + "LockTimeoutSeconds": 600, + "HeartbeatIntervalSeconds": 60, + "MaxConcurrentJobs": 10 + } } diff --git a/src/Influunt.Scheduler.Abstractions/Influunt.Scheduler.Abstractions.csproj b/src/Influunt.Scheduler.Abstractions/Influunt.Scheduler.Abstractions.csproj new file mode 100644 index 0000000..a6b9113 --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Influunt.Scheduler.Abstractions.csproj @@ -0,0 +1,7 @@ + + + net8.0 + enable + enable + + \ No newline at end of file diff --git a/src/Influunt.Scheduler.Abstractions/Models/ExecutionStatus.cs b/src/Influunt.Scheduler.Abstractions/Models/ExecutionStatus.cs new file mode 100644 index 0000000..d9ee44d --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Models/ExecutionStatus.cs @@ -0,0 +1,9 @@ +namespace Influunt.Scheduler.Abstractions.Models; + +public enum ExecutionStatus +{ + Pending, // Awaiting execution (lock not acquired) + Running, // Running (lock acquired) + Completed, // Successfully completed + Failed // Failed +} \ No newline at end of file diff --git a/src/Influunt.Scheduler.Abstractions/Models/IJob.cs b/src/Influunt.Scheduler.Abstractions/Models/IJob.cs new file mode 100644 index 0000000..c22e21b --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Models/IJob.cs @@ -0,0 +1,10 @@ +namespace Influunt.Scheduler.Abstractions.Models; +public interface IJob +{ + Task ExecuteAsync(CancellationToken cancellationToken); +} + +public interface IJob +{ + Task ExecuteAsync(TSettings settings, CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Influunt.Scheduler.Abstractions/Models/JobDefinition.cs b/src/Influunt.Scheduler.Abstractions/Models/JobDefinition.cs new file mode 100644 index 0000000..747af6b --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Models/JobDefinition.cs @@ -0,0 +1,11 @@ +namespace Influunt.Scheduler.Abstractions.Models; + +public class JobDefinition +{ + public string Id { get; set; } + public string Name { get; set; } + public string? Description { get; set; } + public string JobType { get; set; } + public string SettingsType { get; set; } + public bool AllowConcurrentExecution { get; set; } +} \ No newline at end of file diff --git a/src/Influunt.Scheduler.Abstractions/Models/JobExecution.cs b/src/Influunt.Scheduler.Abstractions/Models/JobExecution.cs new file mode 100644 index 0000000..42d7499 --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Models/JobExecution.cs @@ -0,0 +1,21 @@ +namespace Influunt.Scheduler.Abstractions.Models; + +public class JobExecution +{ + public string Id { get; set; } + public string TriggerId { get; set; } + /// The trigger document identity, distinguishing reused trigger names. + public string? TriggerDocumentId { get; set; } + /// The scheduled occurrence consumed by this execution. + public DateTime? ScheduledRunTime { get; set; } + /// The next occurrence calculated when this execution completes. + public DateTime? NextRunTime { get; set; } + public string JobType { get; set; } + public ExecutionStatus Status { get; set; } + public DateTime StartTime { get; set; } + public DateTime? EndTime { get; set; } + public string Result { get; set; } + public string LockId { get; set; } + public DateTime LockExpiry { get; set; } + public DateTime LastHeartbeat { get; set; } +} diff --git a/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs b/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs new file mode 100644 index 0000000..3005c80 --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Models/JobTrigger.cs @@ -0,0 +1,13 @@ +namespace Influunt.Scheduler.Abstractions.Models; + +public class JobTrigger +{ + public string Id { get; set; } + public string TriggerId { get; set; } + public string JobType { get; set; } + public DateTime NextRunTime { get; set; } + public DateTime? CreatedAt { get; set; } + public bool IsRecurring => !string.IsNullOrEmpty(CronExpression); + public string? CronExpression { get; set; } + public string? SettingsJson { get; set; } +} diff --git a/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs b/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs new file mode 100644 index 0000000..2a8e202 --- /dev/null +++ b/src/Influunt.Scheduler.Abstractions/Services/IScheduler.cs @@ -0,0 +1,86 @@ +using Influunt.Scheduler.Abstractions.Models; + +namespace Influunt.Scheduler.Abstractions.Services; + +/// +/// Scheduler service that manages job registration and scheduling. +/// +public interface IScheduler +{ + /// + /// Registers a job with the specified name and optional description. + /// + /// The type of the job implementing . + /// The type of the job settings. + /// The name of the job. + /// An optional description of the job. + /// Whether the job allows concurrent execution. + Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) where TJob : IJob; + + /// + /// Registers a job with the specified name and optional description. + /// + /// The type of the job implementing . + /// The name of the job. + /// An optional description of the job. + /// Whether the job allows concurrent execution. + Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) where TJob : IJob; + + /// + /// Schedules a one-time job to run at the specified time. + /// + /// The type of the job implementing . + /// The time at which the job should run. If null, the job will run as soon as possible. + /// An optional identifier for the trigger. + /// The identifier of the scheduled trigger. + Task ScheduleOneTimeJobAsync(DateTime? runAt = null, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob; + + /// + /// Schedules a recurring job based on the specified cron expression. + /// + /// The type of the job implementing . + /// The cron expression defining the schedule. + /// An optional identifier for the trigger. + /// The identifier of the scheduled trigger. + Task ScheduleRecurringJobAsync(string cronExpression, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob; + + /// + /// Schedules a one-time job with settings to run at the specified time. + /// + /// The type of the job implementing . + /// The type of the job settings. + /// The settings for the job. + /// The time at which the job should run. If null, the job will run as soon as possible. + /// An optional identifier for the trigger. + /// The identifier of the scheduled trigger. + Task ScheduleOneTimeJobAsync(TSettings settings, DateTime? runAt = null, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob; + + /// + /// Schedules a recurring job with settings based on the specified cron expression. + /// + /// The type of the job implementing . + /// The type of the job settings. + /// The cron expression defining the schedule. + /// The settings for the job. + /// An optional identifier for the trigger. + /// The identifier of the scheduled trigger. + Task ScheduleRecurringJobAsync(string cronExpression, TSettings settings, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob; + + /// + /// Unschedules a job with the specified trigger identifier. + /// + /// The identifier of the trigger to unschedule. + Task UnScheduleJobAsync(string triggerId); + + /// + /// Retrieves all registered job definitions. + /// + /// A collection of registered job definitions. + Task> GetRegisteredJobsAsync(); + + /// + /// Retrieves all scheduled job triggers. + /// + /// A collection of scheduled job triggers. + Task> GetScheduledTriggersAsync(); +} diff --git a/src/Influunt.Scheduler/Constants.cs b/src/Influunt.Scheduler/Constants.cs new file mode 100644 index 0000000..eeef404 --- /dev/null +++ b/src/Influunt.Scheduler/Constants.cs @@ -0,0 +1,24 @@ +namespace Influunt.Scheduler; + +/// +/// Constants for the job scheduler module. +/// +public static class Constants +{ + /// + /// Job definitions collection name. + /// + public const string JobDefinitionsCollectionName = "job_definitions"; + + /// + /// Job triggers collection name. + /// + public const string JobTriggersCollectionName = "job_triggers"; + + /// + /// Job executions collection name. + /// + public const string JobExecutionsCollectionName = "job_executions"; + + internal const string JobLocksCollectionName = "job_locks"; +} diff --git a/src/Influunt.Scheduler/CronHelper.cs b/src/Influunt.Scheduler/CronHelper.cs new file mode 100644 index 0000000..6c7ca2a --- /dev/null +++ b/src/Influunt.Scheduler/CronHelper.cs @@ -0,0 +1,12 @@ +using Cronos; + +namespace Influunt.Scheduler; + +public static class CronHelper +{ + public static DateTime? GetNextOccurrence(string cronExpression, DateTime currentTime) + { + CronExpression expression = CronExpression.Parse(cronExpression); + return expression.GetNextOccurrence(currentTime); + } +} diff --git a/src/Influunt.Scheduler/IMongoContext.cs b/src/Influunt.Scheduler/IMongoContext.cs new file mode 100644 index 0000000..d787a5e --- /dev/null +++ b/src/Influunt.Scheduler/IMongoContext.cs @@ -0,0 +1,27 @@ +using MongoDB.Driver; + +namespace Influunt.Scheduler; + +/// +/// Interface for working with MongoDB context +/// +public interface IMongoContext +{ + /// + /// Gets the MongoDB database + /// + IMongoDatabase Database { get; } + + /// + /// Gets the collection of the specified type + /// + /// Type of collection documents + /// Collection name (if null, the type name will be used) + /// MongoDB collection + IMongoCollection GetCollection(string collectionName = null); + + /// + /// Releases resources + /// + void Dispose(); +} \ No newline at end of file diff --git a/src/Influunt.Scheduler/IndexCreationBackgroundService.cs b/src/Influunt.Scheduler/IndexCreationBackgroundService.cs new file mode 100644 index 0000000..2d78644 --- /dev/null +++ b/src/Influunt.Scheduler/IndexCreationBackgroundService.cs @@ -0,0 +1,188 @@ +using Influunt.Scheduler.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using MongoDB.Bson; +using MongoDB.Driver; + +namespace Influunt.Scheduler; + +/// +/// Background service for creating MongoDB indexes for the scheduler module. +/// Runs once when the application starts. +/// +public class IndexCreationBackgroundService : BackgroundService +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + + public IndexCreationBackgroundService( + IServiceProvider serviceProvider, + ILogger logger) + { + _serviceProvider = serviceProvider; + _logger = logger; + } + + public override async Task StartAsync(CancellationToken cancellationToken) + { + await base.StartAsync(cancellationToken); + // Acquisition relies on unique indexes; do not start the scheduler before they exist. + await ExecuteTask!.WaitAsync(cancellationToken); + } + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + _logger.LogInformation("Index Creation Background Service started."); + + try + { + await CreateIndexesAsync(stoppingToken); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error creating MongoDB indexes"); + throw; + } + finally + { + _logger.LogInformation("Index Creation Background Service finished."); + } + } + + /// + /// Creates indexes for scheduler collections. + /// + private async Task CreateIndexesAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("Starting MongoDB index creation for scheduler module..."); + + // Using scope to get dependencies with correct lifetime + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + await CreateJobTriggerIndexesAsync(context, cancellationToken); + await CreateJobDefinitionIndexesAsync(context, cancellationToken); + await CreateJobExecutionIndexesAsync(context, cancellationToken); + + _logger.LogInformation("MongoDB index creation for scheduler module completed."); + } + + private async Task CreateJobDefinitionIndexesAsync(IMongoContext context, CancellationToken cancellationToken) + { + var collection = context.GetCollection(Constants.JobDefinitionsCollectionName); + + // Unique index by job name + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.Name), + new CreateIndexOptions { Unique = true, Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Created unique index JobDefinition.Name"); + + // Unique index by job type + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.JobType), + new CreateIndexOptions { Unique = true, Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Created unique index JobDefinition.JobType"); + } + + private async Task CreateJobTriggerIndexesAsync(IMongoContext context, CancellationToken cancellationToken) + { + var collection = context.GetCollection(Constants.JobTriggersCollectionName); + + // Index for searching triggers by next run time + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.NextRunTime), + new CreateIndexOptions { Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Created index JobTrigger.NextRunTime"); + + // Unique index by trigger identifier + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.TriggerId), + new CreateIndexOptions { Unique = true, Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Created unique index JobTrigger.TriggerId"); + } + + private async Task CreateJobExecutionIndexesAsync(IMongoContext context, CancellationToken cancellationToken) + { + var collection = context.GetCollection(Constants.JobExecutionsCollectionName); + + // One durable receipt per trigger generation and scheduled occurrence. + // Exclude legacy documents, which do not yet have these fields. + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.TriggerDocumentId).Ascending(x => x.ScheduledRunTime), + new CreateIndexOptions + { + Name = "TriggerOccurrence", + Unique = true, + PartialFilterExpression = Builders.Filter.And( + Builders.Filter.Type(x => x.TriggerDocumentId, BsonType.String), + Builders.Filter.Type(x => x.ScheduledRunTime, BsonType.DateTime)) + }), + cancellationToken: cancellationToken); + + using var indexesCursor = await collection.Indexes.ListAsync(cancellationToken); + foreach (var index in await indexesCursor.ToListAsync(cancellationToken)) + { + var keys = index["key"].AsBsonDocument; + if (keys.ElementCount != 1 || !keys.Contains("TriggerId") || !index.GetValue("unique", false).AsBoolean) + continue; + try + { + await collection.Indexes.DropOneAsync(index["name"].AsString, cancellationToken); + } + catch (MongoCommandException ex) when (ex.Code == 27) + { + // Another scheduler instance already migrated the index. + } + } + + // Index for searching running jobs by lock + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.LockId).Ascending(x => x.Status), + new CreateIndexOptions { Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Created compound index JobExecution.LockId+Status"); + + // Index by job type and status + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.JobType).Ascending(x => x.Status), + new CreateIndexOptions { Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Created compound index JobExecution.JobType+Status"); + + // Index for searching executions by trigger and status (used in ScheduleJobAsync) + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.TriggerId).Ascending(x => x.Status), + new CreateIndexOptions { Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Created compound index JobExecution.TriggerId+Status"); + + // Index for searching expired locks (used in CreateAndStartJobExecution and TryCaptureExistingExecutionLock) + await collection.Indexes.CreateOneAsync( + new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.LockExpiry), + new CreateIndexOptions { Background = true }), + cancellationToken: cancellationToken); + + _logger.LogDebug("Created index JobExecution.LockExpiry"); + } +} diff --git a/src/Influunt.Scheduler/Influunt.Scheduler.csproj b/src/Influunt.Scheduler/Influunt.Scheduler.csproj new file mode 100644 index 0000000..438e796 --- /dev/null +++ b/src/Influunt.Scheduler/Influunt.Scheduler.csproj @@ -0,0 +1,21 @@ + + + net8.0 + enable + enable + + + + + + + + + + + + + + + + diff --git a/src/Influunt.Scheduler/JobDefinitionClassMap.cs b/src/Influunt.Scheduler/JobDefinitionClassMap.cs new file mode 100644 index 0000000..d81a0d2 --- /dev/null +++ b/src/Influunt.Scheduler/JobDefinitionClassMap.cs @@ -0,0 +1,21 @@ +using Influunt.Scheduler.Abstractions.Models; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.IdGenerators; +using MongoDB.Bson.Serialization.Serializers; + +namespace Influunt.Scheduler; + +public class JobDefinitionClassMap : BsonClassMap +{ + public JobDefinitionClassMap() + { + AutoMap(); + MapIdMember(x => x.Id) + .SetIdGenerator(StringObjectIdGenerator.Instance) + .SetSerializer(new StringSerializer(BsonType.ObjectId)); + SetIgnoreExtraElements(true); + MapProperty(x => x.Id) + .SetIgnoreIfNull(true); + } +} diff --git a/src/Influunt.Scheduler/JobExecutionClassMap.cs b/src/Influunt.Scheduler/JobExecutionClassMap.cs new file mode 100644 index 0000000..208a046 --- /dev/null +++ b/src/Influunt.Scheduler/JobExecutionClassMap.cs @@ -0,0 +1,21 @@ +using Influunt.Scheduler.Abstractions.Models; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.IdGenerators; +using MongoDB.Bson.Serialization.Serializers; + +namespace Influunt.Scheduler; + +public class JobExecutionClassMap : BsonClassMap +{ + public JobExecutionClassMap() + { + AutoMap(); + MapIdMember(x => x.Id) + .SetIdGenerator(StringObjectIdGenerator.Instance) + .SetSerializer(new StringSerializer(BsonType.ObjectId)); + SetIgnoreExtraElements(true); + MapProperty(x => x.Id) + .SetIgnoreIfNull(true); + } +} diff --git a/src/Influunt.Scheduler/JobScheduler.cs b/src/Influunt.Scheduler/JobScheduler.cs new file mode 100644 index 0000000..54d8c87 --- /dev/null +++ b/src/Influunt.Scheduler/JobScheduler.cs @@ -0,0 +1,671 @@ +using System.Collections.Concurrent; +using System.Threading; +using System.Text.Json; +using System.Reflection; +using System.Runtime.ExceptionServices; +using Influunt.Scheduler.Abstractions.Models; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace Influunt.Scheduler; + +internal class JobScheduler : IScheduler, IHostedService, IDisposable +{ + private readonly IServiceProvider _serviceProvider; + private readonly ILogger _logger; + private readonly JobSchedulerOptions _options; + private readonly IMongoContext _context; + private readonly TimeProvider _timeProvider; + private readonly string _instanceId; + private readonly ConcurrentDictionary _runningJobs = new(); + private readonly SemaphoreSlim _scanLock = new(1, 1); + private readonly SemaphoreSlim _heartbeatLock = new(1, 1); + private readonly object _lifecycleLock = new(); + private readonly CancellationTokenSource _scanCts = new(); + private Timer? _scanTimer; + private Timer? _heartbeatTimer; + private volatile bool _stopping; + private volatile bool _disposed; + + public JobScheduler(IServiceProvider serviceProvider, IOptions options, ILogger logger, IMongoContext context, TimeProvider? timeProvider = null) + { + _serviceProvider = serviceProvider; + _logger = logger; + _options = options.Value; + _context = context; + _timeProvider = timeProvider ?? TimeProvider.System; + + if (_options.ScanIntervalSeconds <= 0 || _options.MaxConcurrentJobs <= 0 || _options.ScanBatchSize <= 0) + throw new ArgumentException("Scan interval, concurrency and batch size must be positive.", nameof(options)); + if (_options.HeartbeatIntervalSeconds <= 0 || _options.LockTimeoutSeconds <= _options.HeartbeatIntervalSeconds) + throw new ArgumentException("Lock timeout must exceed the positive heartbeat interval.", nameof(options)); + + _instanceId = $"{Environment.MachineName}_{Guid.NewGuid():N}"; + } + + private IMongoCollection _jobDefinitions => _context.GetCollection(Constants.JobDefinitionsCollectionName); + private IMongoCollection _triggers => _context.GetCollection(Constants.JobTriggersCollectionName); + private IMongoCollection _executions => _context.GetCollection(Constants.JobExecutionsCollectionName); + private IMongoCollection _jobLocks => _context.GetCollection(Constants.JobLocksCollectionName); + private DateTime UtcNow => _timeProvider.GetUtcNow().UtcDateTime; + + public Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("JobScheduler starting. InstanceId: {InstanceId}", _instanceId); + + _scanTimer = new Timer(ScanTriggers, null, TimeSpan.Zero, TimeSpan.FromSeconds(_options.ScanIntervalSeconds)); + _heartbeatTimer = new Timer(Heartbeat, null, TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds), TimeSpan.FromSeconds(_options.HeartbeatIntervalSeconds)); + + return Task.CompletedTask; + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogInformation("JobScheduler stopping..."); + + lock (_lifecycleLock) + _stopping = true; + _scanTimer?.Change(Timeout.Infinite, 0); + _scanCts.Cancel(); + foreach (var job in _runningJobs.Values) + job.Cancel(); + + try + { + await _scanLock.WaitAsync(cancellationToken); + _scanLock.Release(); + foreach (var job in _runningJobs.Values) + job.Cancel(); + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); + await Task.WhenAll(_runningJobs.Values.Select(j => j.Completion.Task)).WaitAsync(timeoutCts.Token); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Shutdown timeout reached. Some jobs may still be running."); + } + finally + { + // Keep renewing leases while cancelled jobs are still winding down. + _heartbeatTimer?.Change(Timeout.Infinite, 0); + try + { + await _heartbeatLock.WaitAsync(cancellationToken); + _heartbeatLock.Release(); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + } + } + + public async Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) + where TJob : IJob + { + var jobDef = new JobDefinition + { + Name = name, + Description = description, + JobType = typeof(TJob)!.AssemblyQualifiedName!, + SettingsType = typeof(TSettings)!.AssemblyQualifiedName!, + AllowConcurrentExecution = allowConcurrentExecution + }; + await _jobDefinitions.ReplaceOneAsync(d => d.Name == name, jobDef, new ReplaceOptions { IsUpsert = true }); + } + + public async Task RegisterJobAsync(string name, string? description = null, bool allowConcurrentExecution = false) + where TJob : IJob + { + var jobDef = new JobDefinition + { + Name = name, + Description = description, + JobType = typeof(TJob)!.AssemblyQualifiedName!, + AllowConcurrentExecution = allowConcurrentExecution + }; + await _jobDefinitions.ReplaceOneAsync(d => d.Name == name, jobDef, new ReplaceOptions { IsUpsert = true }); + } + + public async Task ScheduleOneTimeJobAsync(DateTime? runAt = null, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName, cancellationToken: cancellationToken); + var registeredJob = await cursor.FirstOrDefaultAsync(cancellationToken); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, + NextRunTime = runAt.HasValue ? runAt.Value.ToUniversalTime() : UtcNow, + CreatedAt = UtcNow + }; + await _triggers.InsertOneAsync(trigger, cancellationToken: cancellationToken); + return trigger.TriggerId; + } + + public async Task ScheduleRecurringJobAsync(string cronExpression, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName, cancellationToken: cancellationToken); + var registeredJob = await cursor.FirstOrDefaultAsync(cancellationToken); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + + var nextRun = CronHelper.GetNextOccurrence(cronExpression, UtcNow); + if (!nextRun.HasValue) + throw new ArgumentException("Cron expression does not produce any future occurrence"); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, + NextRunTime = nextRun.Value, + CronExpression = cronExpression, + CreatedAt = UtcNow + }; + await _triggers.InsertOneAsync(trigger, cancellationToken: cancellationToken); + return trigger.TriggerId; + } + + public async Task ScheduleOneTimeJobAsync(TSettings settings, DateTime? runAt = null, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName, cancellationToken: cancellationToken); + var registeredJob = await cursor.FirstOrDefaultAsync(cancellationToken); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + if (registeredJob.SettingsType != typeof(TSettings).AssemblyQualifiedName) + throw new InvalidOperationException("The task settings type does not match the registered type."); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, + NextRunTime = runAt.HasValue ? runAt.Value.ToUniversalTime() : UtcNow, + SettingsJson = JsonSerializer.Serialize(settings), + CreatedAt = UtcNow + }; + await _triggers.InsertOneAsync(trigger, cancellationToken: cancellationToken); + return trigger.TriggerId; + } + + public async Task ScheduleRecurringJobAsync(string cronExpression, TSettings settings, string? triggerId = null, CancellationToken cancellationToken = default) where TJob : IJob + { + var cursor = await _jobDefinitions.FindAsync(x => x.JobType == typeof(TJob).AssemblyQualifiedName, cancellationToken: cancellationToken); + var registeredJob = await cursor.FirstOrDefaultAsync(cancellationToken); + if (registeredJob is null) + throw new InvalidOperationException("Job not registered"); + if (registeredJob.SettingsType != typeof(TSettings).AssemblyQualifiedName) + throw new InvalidOperationException("The task settings type does not match the registered type."); + + var nextRun = CronHelper.GetNextOccurrence(cronExpression, UtcNow); + if (!nextRun.HasValue) + throw new ArgumentException("Cron expression does not produce any future occurrence"); + + var trigger = new JobTrigger + { + TriggerId = string.IsNullOrEmpty(triggerId) ? Guid.NewGuid().ToString() : triggerId, + JobType = registeredJob.JobType, + NextRunTime = nextRun.Value, + CronExpression = cronExpression, + SettingsJson = JsonSerializer.Serialize(settings), + CreatedAt = UtcNow + }; + await _triggers.InsertOneAsync(trigger, cancellationToken: cancellationToken); + return trigger.TriggerId; + } + + public async Task UnScheduleJobAsync(string triggerId) + { + await _triggers.DeleteOneAsync(x => x.TriggerId == triggerId); + } + + public async Task> GetRegisteredJobsAsync() + { + var cursor = await _jobDefinitions.FindAsync(x => true); + return await cursor.ToListAsync(); + } + + public async Task> GetScheduledTriggersAsync() + { + var cursor = await _triggers.FindAsync(x => true); + return await cursor.ToListAsync(); + } + + private void ScanTriggers(object? state) => _ = ScanTriggersAsync(); + + internal async Task ScanTriggersAsync() + { + if (_stopping || _disposed || !_scanLock.Wait(0)) + return; + + try + { + var cancellationToken = _scanCts.Token; + var filter = Builders.Filter.Lte(t => t.NextRunTime, UtcNow); + // Batch the cursor, but keep looking past busy triggers until actual worker slots are full. + using var cursor = await _triggers.Find(filter, new FindOptions { BatchSize = _options.ScanBatchSize }) + .SortBy(t => t.NextRunTime).ThenBy(t => t.Id) + .ToCursorAsync(cancellationToken); + + while (!_stopping && _runningJobs.Count < _options.MaxConcurrentJobs && await cursor.MoveNextAsync(cancellationToken)) + { + foreach (var trigger in cursor.Current) + { + if (_stopping || _runningJobs.Count >= _options.MaxConcurrentJobs) + return; + try + { + await ScheduleJobAsync(trigger, cancellationToken); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error scheduling job for trigger {TriggerId}", trigger.TriggerId); + } + } + } + } + catch (OperationCanceledException) when (_scanCts.IsCancellationRequested) + { + } + catch (Exception ex) + { + _logger.LogError(ex, "Error scanning triggers"); + } + finally + { + _scanLock.Release(); + } + } + + internal async Task ScheduleJobAsync(JobTrigger trigger, CancellationToken cancellationToken = default) + { + if (_stopping) + return false; + + var jobDef = await _jobDefinitions.Find(d => d.JobType == trigger.JobType).FirstOrDefaultAsync(cancellationToken); + if (jobDef == null) + return false; + + var lockId = $"{_instanceId}_{Guid.NewGuid():N}"; + var ownsJobLock = false; + var started = false; + try + { + if (!jobDef.AllowConcurrentExecution) + { + ownsJobLock = await TryAcquireJobLockAsync(trigger.JobType, lockId, cancellationToken); + if (!ownsJobLock) + return false; + + // Also respect active executions persisted before job-type leases were introduced. + var active = Builders.Filter.And( + Builders.Filter.Eq(e => e.JobType, trigger.JobType), + Builders.Filter.In(e => e.Status, new[] { ExecutionStatus.Pending, ExecutionStatus.Running }), + Builders.Filter.Ne(e => e.LockId, null), + Builders.Filter.Gt(e => e.LockExpiry, UtcNow)); + if (await _executions.Find(active).AnyAsync(cancellationToken)) + return false; + } + + // A different scan may already have removed or advanced this occurrence. + if (!await _triggers.Find(CurrentOccurrence(trigger)).AnyAsync(cancellationToken)) + return false; + + var execution = await GetOrCreateExecutionAsync(trigger, cancellationToken); + var terminal = execution.Status is ExecutionStatus.Completed or ExecutionStatus.Failed; + if (terminal) + { + // Repair a crash between persisting completion and advancing the trigger. + await UpdateTriggerNextRunAsync(trigger, execution.NextRunTime ?? GetNextRunTime(trigger, execution.EndTime ?? execution.StartTime)); + return false; + } + + var now = UtcNow; + if (execution.LockId != null && execution.LockExpiry > now) + return false; + + var filter = Builders.Filter.And( + Builders.Filter.Eq(e => e.Id, execution.Id), + Builders.Filter.Eq(e => e.LockId, execution.LockId), + execution.Status == ExecutionStatus.Pending + ? Builders.Filter.Eq(e => e.Status, ExecutionStatus.Pending) | Builders.Filter.Exists(e => e.Status, false) + : Builders.Filter.Eq(e => e.Status, execution.Status)); + filter &= Builders.Filter.Or( + Builders.Filter.Eq(e => e.LockId, null), + Builders.Filter.Lte(e => e.LockExpiry, now)); + + var update = Builders.Update + .Set(e => e.TriggerDocumentId, trigger.Id) + .Set(e => e.ScheduledRunTime, trigger.NextRunTime) + .Set(e => e.JobType, trigger.JobType) + .Set(e => e.Status, ExecutionStatus.Running) + .Set(e => e.StartTime, now) + .Set(e => e.LockId, lockId) + .Set(e => e.LockExpiry, now.AddSeconds(_options.LockTimeoutSeconds)) + .Set(e => e.LastHeartbeat, now) + .Unset(e => e.EndTime) + .Unset(e => e.NextRunTime) + .Unset(e => e.Result); + var acquired = await _executions.FindOneAndUpdateAsync(filter, update, + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }, cancellationToken); + if (acquired == null) + return false; + + if (_stopping || acquired.LockExpiry <= UtcNow || + !await _triggers.Find(CurrentOccurrence(trigger)).AnyAsync(cancellationToken) || + (ownsJobLock && !await RenewJobLockAsync(trigger.JobType, lockId))) + { + await AbandonExecutionAsync(acquired); + return false; + } + + var running = new RunningJob(acquired, jobDef, ownsJobLock); + lock (_lifecycleLock) + { + if (!_stopping) + { + _runningJobs[lockId] = running; + started = true; + } + } + if (!started) + { + running.Cts.Dispose(); + await AbandonExecutionAsync(acquired); + return false; + } + _ = Task.Run(() => ExecuteJobAsync(trigger, running)); + return true; + } + finally + { + if (ownsJobLock && !started) + await ReleaseJobLockAsync(trigger.JobType, lockId); + } + } + + private async Task GetOrCreateExecutionAsync(JobTrigger trigger, CancellationToken cancellationToken) + { + var occurrence = Builders.Filter.And( + Builders.Filter.Eq(e => e.TriggerDocumentId, trigger.Id), + Builders.Filter.Eq(e => e.ScheduledRunTime, trigger.NextRunTime)); + var existing = await _executions.Find(occurrence).SingleOrDefaultAsync(cancellationToken); + if (existing != null) + return existing; + + // Only old trigger documents can inherit a receipt without a recorded generation. + // A newly scheduled trigger may reuse an old name and deliberately run in the past. + var legacy = trigger.CreatedAt == null + ? await _executions.Find(e => e.TriggerId == trigger.TriggerId && e.TriggerDocumentId == null).FirstOrDefaultAsync(cancellationToken) + : null; + if (legacy != null && (legacy.Status is ExecutionStatus.Pending or ExecutionStatus.Running || HasConsumedOccurrence(legacy, trigger))) + { + try + { + var migrated = await _executions.FindOneAndUpdateAsync( + Builders.Filter.Eq(e => e.Id, legacy.Id) & Builders.Filter.Eq(e => e.TriggerDocumentId, null), + Builders.Update.Set(e => e.TriggerDocumentId, trigger.Id).Set(e => e.ScheduledRunTime, trigger.NextRunTime), + new FindOneAndUpdateOptions { ReturnDocument = ReturnDocument.After }, cancellationToken); + if (migrated != null) + return migrated; + } + catch (MongoException ex) when (IsDuplicateKey(ex)) + { + return await _executions.Find(occurrence).SingleAsync(cancellationToken); + } + } + + // Never delete terminal receipts, even when another occurrence or trigger generation starts. + var update = Builders.Update + .SetOnInsert(e => e.TriggerId, trigger.TriggerId) + .SetOnInsert(e => e.JobType, trigger.JobType) + .SetOnInsert(e => e.Status, ExecutionStatus.Pending); + try + { + return await _executions.FindOneAndUpdateAsync(occurrence, update, + new FindOneAndUpdateOptions { IsUpsert = true, ReturnDocument = ReturnDocument.After }, cancellationToken); + } + catch (MongoException ex) when (IsDuplicateKey(ex)) + { + return await _executions.Find(occurrence).SingleAsync(cancellationToken); + } + } + + private static bool HasConsumedOccurrence(JobExecution execution, JobTrigger trigger) + { + if (execution.TriggerDocumentId != null && execution.ScheduledRunTime.HasValue) + return execution.TriggerDocumentId == trigger.Id && execution.ScheduledRunTime.Value >= trigger.NextRunTime; + + // Old scheduler documents have neither trigger generation nor scheduled occurrence. + return execution.EndTime.HasValue && execution.EndTime.Value >= trigger.NextRunTime; + } + + private static FilterDefinition CurrentOccurrence(JobTrigger trigger) => + Builders.Filter.And( + Builders.Filter.Eq(t => t.Id, trigger.Id), + Builders.Filter.Eq(t => t.NextRunTime, trigger.NextRunTime)); + + private async Task TryAcquireJobLockAsync(string jobType, string lockId, CancellationToken cancellationToken) + { + var now = UtcNow; + var filter = Builders.Filter.And( + Builders.Filter.Eq(l => l.Id, jobType), + Builders.Filter.Or( + Builders.Filter.Eq(l => l.LockId, null), + Builders.Filter.Lte(l => l.LockExpiry, now))); + try + { + await _jobLocks.FindOneAndUpdateAsync(filter, + Builders.Update.Set(l => l.LockId, lockId).Set(l => l.LockExpiry, now.AddSeconds(_options.LockTimeoutSeconds)), + new FindOneAndUpdateOptions { IsUpsert = true }, cancellationToken); + return true; + } + catch (MongoException ex) when (IsDuplicateKey(ex)) + { + return false; + } + } + + private static bool IsDuplicateKey(MongoException exception) => exception switch + { + MongoWriteException write => write.WriteError?.Category == ServerErrorCategory.DuplicateKey || write.WriteError?.Code == 11000, + MongoCommandException command => command.Code == 11000, + _ => false + }; + + private async Task RenewJobLockAsync(string jobType, string lockId) + { + var now = UtcNow; + var result = await _jobLocks.UpdateOneAsync(l => l.Id == jobType && l.LockId == lockId && l.LockExpiry > now, + Builders.Update.Set(l => l.LockExpiry, now.AddSeconds(_options.LockTimeoutSeconds))); + return result.MatchedCount == 1; + } + + private Task ReleaseJobLockAsync(string jobType, string lockId) => + _jobLocks.UpdateOneAsync(l => l.Id == jobType && l.LockId == lockId, + Builders.Update.Unset(l => l.LockId).Unset(l => l.LockExpiry)); + + private async Task ExecuteJobAsync(JobTrigger trigger, RunningJob running) + { + var execution = running.Execution; + var jobDef = running.Definition; + try + { + var status = ExecutionStatus.Completed; + var result = "Success"; + try + { + running.Cts.Token.ThrowIfCancellationRequested(); + using var scope = _serviceProvider.CreateScope(); + var jobType = Type.GetType(jobDef.JobType) ?? throw new InvalidOperationException($"Cannot load job type {jobDef.JobType}"); + var job = scope.ServiceProvider.GetRequiredService(jobType); + if (string.IsNullOrEmpty(jobDef.SettingsType)) + { + await ((IJob)job).ExecuteAsync(running.Cts.Token); + } + else + { + var settingsType = Type.GetType(jobDef.SettingsType) ?? throw new InvalidOperationException($"Cannot load settings type {jobDef.SettingsType}"); + var settings = string.IsNullOrEmpty(trigger.SettingsJson) + ? Activator.CreateInstance(settingsType) + : JsonSerializer.Deserialize(trigger.SettingsJson, settingsType); + var method = typeof(IJob<>).MakeGenericType(settingsType).GetMethod("ExecuteAsync")!; + Task task; + try + { + task = (Task)method.Invoke(job, new[] { settings, (object)running.Cts.Token })!; + } + catch (TargetInvocationException ex) when (ex.InnerException != null) + { + ExceptionDispatchInfo.Capture(ex.InnerException).Throw(); + throw; + } + await task; + } + } + catch (OperationCanceledException) when (running.Cts.IsCancellationRequested) + { + // Interrupted work remains recoverable instead of consuming the occurrence. + await AbandonExecutionAsync(execution); + return; + } + catch (Exception ex) + { + status = ExecutionStatus.Failed; + result = ex.ToString(); + _logger.LogError(ex, "Job {JobName} (TriggerId: {TriggerId}) failed", jobDef.Name, trigger.TriggerId); + } + + var endTime = UtcNow; + var nextRun = GetNextRunTime(trigger, endTime); + var persisted = await _executions.UpdateOneAsync(OwnedExecution(execution, endTime), + Builders.Update + .Set(e => e.Status, status) + .Set(e => e.EndTime, endTime) + .Set(e => e.Result, result) + .Set(e => e.NextRunTime, nextRun) + .Unset(e => e.LockExpiry)); + if (persisted.MatchedCount == 0) + { + _logger.LogWarning("Lease lost for execution {ExecutionId}; ignoring its result", execution.Id); + return; + } + + // Terminal status is durable before advancing the trigger. Any node can finish this step. + await UpdateTriggerNextRunAsync(trigger, nextRun); + _logger.LogInformation("Job {JobName} (ExecutionId: {ExecutionId}) finished with status {Status}", jobDef.Name, execution.Id, status); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error persisting execution {ExecutionId}", execution.Id); + } + finally + { + try + { + if (running.OwnsJobLock) + await ReleaseJobLockAsync(execution.JobType, execution.LockId); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error releasing job lock for execution {ExecutionId}", execution.Id); + } + _runningJobs.TryRemove(execution.LockId, out _); + running.Completion.TrySetResult(); + running.Cts.Dispose(); + } + } + + private static FilterDefinition OwnedExecution(JobExecution execution, DateTime now) => + Builders.Filter.And( + Builders.Filter.Eq(e => e.Id, execution.Id), + Builders.Filter.Eq(e => e.LockId, execution.LockId), + Builders.Filter.Eq(e => e.Status, ExecutionStatus.Running), + Builders.Filter.Gt(e => e.LockExpiry, now)); + + private Task AbandonExecutionAsync(JobExecution execution) => + _executions.UpdateOneAsync(e => e.Id == execution.Id && e.LockId == execution.LockId && e.Status == ExecutionStatus.Running, + Builders.Update.Set(e => e.LockExpiry, UtcNow)); + + private static DateTime? GetNextRunTime(JobTrigger trigger, DateTime completedAt) => + trigger.IsRecurring ? CronHelper.GetNextOccurrence(trigger.CronExpression!, completedAt) : null; + + private Task UpdateTriggerNextRunAsync(JobTrigger trigger, DateTime? nextRun) => + nextRun.HasValue + ? _triggers.UpdateOneAsync(CurrentOccurrence(trigger), Builders.Update.Set(t => t.NextRunTime, nextRun.Value)) + : _triggers.DeleteOneAsync(CurrentOccurrence(trigger)); + + private void Heartbeat(object? state) => _ = HeartbeatAsync(); + + internal async Task HeartbeatAsync() + { + if (_disposed || !_heartbeatLock.Wait(0)) + return; + try + { + foreach (var running in _runningJobs.Values) + { + try + { + var execution = running.Execution; + if (running.OwnsJobLock && !await RenewJobLockAsync(execution.JobType, execution.LockId)) + { + running.Cancel(); + continue; + } + var now = UtcNow; + var renewed = await _executions.UpdateOneAsync(OwnedExecution(execution, now), + Builders.Update + .Set(e => e.LockExpiry, now.AddSeconds(_options.LockTimeoutSeconds)) + .Set(e => e.LastHeartbeat, now)); + if (renewed.MatchedCount == 0) + running.Cancel(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error renewing execution lease"); + } + } + } + finally + { + _heartbeatLock.Release(); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + lock (_lifecycleLock) + _stopping = true; + _scanTimer?.Dispose(); + _heartbeatTimer?.Dispose(); + _scanCts.Cancel(); + foreach (var running in _runningJobs.Values) + running.Cancel(); + } + + private sealed class RunningJob(JobExecution execution, JobDefinition definition, bool ownsJobLock) + { + public JobExecution Execution { get; } = execution; + public JobDefinition Definition { get; } = definition; + public bool OwnsJobLock { get; } = ownsJobLock; + public CancellationTokenSource Cts { get; } = new(); + public TaskCompletionSource Completion { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public void Cancel() + { + try { Cts.Cancel(); } + catch (ObjectDisposedException) { } + } + } +} diff --git a/src/Influunt.Scheduler/JobSchedulerOptions.cs b/src/Influunt.Scheduler/JobSchedulerOptions.cs new file mode 100644 index 0000000..f33a55d --- /dev/null +++ b/src/Influunt.Scheduler/JobSchedulerOptions.cs @@ -0,0 +1,22 @@ +namespace Influunt.Scheduler; + + public class JobSchedulerOptions + { + /// MongoDB connection string. + public string ConnectionString { get; set; } = ""; + + /// Trigger scan interval (seconds). + public int ScanIntervalSeconds { get; set; } = 60; + + /// Lock timeout duration (seconds). + public int LockTimeoutSeconds { get; set; } = 300; + + /// Heartbeat update interval (seconds). + public int HeartbeatIntervalSeconds { get; set; } = 60; + + /// Maximum number of concurrently running jobs. + public int MaxConcurrentJobs { get; set; } = 10; + + /// Batch size for scan queries. + public int ScanBatchSize { get; set; } = 50; + } diff --git a/src/Influunt.Scheduler/JobTriggerClassMap.cs b/src/Influunt.Scheduler/JobTriggerClassMap.cs new file mode 100644 index 0000000..d80db24 --- /dev/null +++ b/src/Influunt.Scheduler/JobTriggerClassMap.cs @@ -0,0 +1,20 @@ +using Influunt.Scheduler.Abstractions.Models; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Bson.Serialization.IdGenerators; +using MongoDB.Bson.Serialization.Serializers; + +namespace Influunt.Scheduler; + +public class JobTriggerClassMap : BsonClassMap +{ + public JobTriggerClassMap() + { + AutoMap(); + MapIdMember(x => x.Id) + .SetIdGenerator(StringObjectIdGenerator.Instance) + .SetSerializer(new StringSerializer(BsonType.ObjectId)); + UnmapMember(x => x.IsRecurring); + SetIgnoreExtraElements(true); + } +} diff --git a/src/Influunt.Scheduler/JobTypeLock.cs b/src/Influunt.Scheduler/JobTypeLock.cs new file mode 100644 index 0000000..429bf36 --- /dev/null +++ b/src/Influunt.Scheduler/JobTypeLock.cs @@ -0,0 +1,9 @@ +namespace Influunt.Scheduler; + +internal class JobTypeLock +{ + // MongoDB's unique _id index makes acquisition atomic across scheduler instances. + public string Id { get; set; } = null!; + public string? LockId { get; set; } + public DateTime LockExpiry { get; set; } +} diff --git a/src/Influunt.Scheduler/MongoContext.cs b/src/Influunt.Scheduler/MongoContext.cs new file mode 100644 index 0000000..a1a182f --- /dev/null +++ b/src/Influunt.Scheduler/MongoContext.cs @@ -0,0 +1,87 @@ +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace Influunt.Scheduler; + +/// +/// MongoDB context implementation +/// +public class MongoContext : IMongoContext, IDisposable +{ + private readonly MongoUrl _url; + private readonly Lazy _mongoClient; + private IMongoDatabase _database; + private bool _disposed; + + /// + /// Creates a new instance of MongoContext with the specified connection string + /// + /// Job scheduler options + public MongoContext(IOptions options) + { + if (options?.Value?.ConnectionString == null) + throw new ArgumentException("Connection string cannot be null or empty", nameof(options)); + + var connectionString = options.Value.ConnectionString; + + // Connection string parsing + _url = MongoUrl.Create(connectionString); + + // Lazy creation of MongoClient + _mongoClient = new Lazy(() => new MongoClient(_url)); + } + + /// + /// Gets the MongoDB database + /// + public IMongoDatabase Database => _database ??= _mongoClient.Value.GetDatabase(_url.DatabaseName); + + /// + /// Gets the collection of the specified type + /// + /// Type of collection documents + /// Collection name (if null, the type name will be used) + /// MongoDB collection + public IMongoCollection GetCollection(string? collectionName = null) + { + if (_disposed) + throw new ObjectDisposedException(nameof(MongoContext)); + + var name = collectionName ?? typeof(T).Name.ToLowerInvariant(); + return Database.GetCollection(name); + } + + /// + /// Releases resources + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Releases resources + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + if (_mongoClient.IsValueCreated && _mongoClient.Value is IDisposable disposable) + disposable.Dispose(); + } + + _disposed = true; + } + } + + /// + /// Finalizer + /// + ~MongoContext() + { + Dispose(false); + } +} \ No newline at end of file diff --git a/src/Influunt.Scheduler/SchedulerModule.cs b/src/Influunt.Scheduler/SchedulerModule.cs new file mode 100644 index 0000000..d711527 --- /dev/null +++ b/src/Influunt.Scheduler/SchedulerModule.cs @@ -0,0 +1,45 @@ +using Influunt.Scheduler.Abstractions.Models; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using MongoDB.Bson.Serialization; + +namespace Influunt.Scheduler; + +public static class SchedulerModule +{ + private static readonly object ClassMapLock = new(); + + public static IServiceCollection AddJobScheduler(this IServiceCollection services, Action configureOptions) + { + services.Configure(configureOptions); + + // Register IMongoContext + services.AddSingleton(); + + // Register JobScheduler + services.AddSingleton(); + + services.AddHostedService(); + + // Register BSON class maps + lock (ClassMapLock) + { + if (!BsonClassMap.IsClassMapRegistered(typeof(JobDefinition))) + BsonClassMap.RegisterClassMap(new JobDefinitionClassMap()); + if (!BsonClassMap.IsClassMapRegistered(typeof(JobExecution))) + BsonClassMap.RegisterClassMap(new JobExecutionClassMap()); + if (!BsonClassMap.IsClassMapRegistered(typeof(JobTrigger))) + BsonClassMap.RegisterClassMap(new JobTriggerClassMap()); + } + + return services; + } + + public static IServiceCollection AddJobSchedulerHost(this IServiceCollection services, Action configureOptions) + { + services.AddJobScheduler(configureOptions); + services.AddSingleton(s => (JobScheduler)s.GetRequiredService()); + return services; + } +} diff --git a/src/Influunt.Storage/IMongoContext.cs b/src/Influunt.Storage/IMongoContext.cs new file mode 100644 index 0000000..fe738e4 --- /dev/null +++ b/src/Influunt.Storage/IMongoContext.cs @@ -0,0 +1,27 @@ +using MongoDB.Driver; + +namespace Influunt.Storage; + +/// +/// Интерфейс для работы с MongoDB контекстом +/// +public interface IMongoContext +{ + /// + /// Получает базу данных MongoDB + /// + IMongoDatabase Database { get; } + + /// + /// Получает коллекцию указанного типа + /// + /// Тип документов коллекции + /// Имя коллекции (если null, будет использовано имя типа) + /// Коллекция MongoDB + IMongoCollection GetCollection(string collectionName = null); + + /// + /// Освобождает ресурсы + /// + void Dispose(); +} \ No newline at end of file diff --git a/src/Influunt.Storage/Influunt.Storage.csproj b/src/Influunt.Storage/Influunt.Storage.csproj index 848bbed..85fff78 100644 --- a/src/Influunt.Storage/Influunt.Storage.csproj +++ b/src/Influunt.Storage/Influunt.Storage.csproj @@ -18,5 +18,8 @@ + + + - \ No newline at end of file + diff --git a/src/Influunt.Storage/MongoContext.cs b/src/Influunt.Storage/MongoContext.cs new file mode 100644 index 0000000..a6aaa38 --- /dev/null +++ b/src/Influunt.Storage/MongoContext.cs @@ -0,0 +1,88 @@ +using System; +using MongoDB.Driver; + +namespace Influunt.Storage; + +/// +/// Реализация контекста MongoDB +/// +public class MongoContext : IMongoContext, IDisposable +{ + private readonly MongoUrl _url; + private readonly Lazy _mongoClient; + private IMongoDatabase _database; + private bool _disposed; + + /// + /// Создает новый экземпляр MongoContext с указанной строкой подключения + /// + /// Строка подключения к MongoDB + public MongoContext(string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("Connection string cannot be null or empty", nameof(connectionString)); + + // Парсинг строки подключения + _url = MongoUrl.Create(connectionString); + + // Lazy создание MongoClient + _mongoClient = new Lazy(() => new MongoClient(_url)); + } + + /// + /// Получает базу данных MongoDB + /// + public IMongoDatabase Database => _database ??= _mongoClient.Value.GetDatabase(_url.DatabaseName); + + /// + /// Получает коллекцию указанного типа + /// + /// Тип документов коллекции + /// Имя коллекции (если null, будет использовано имя типа) + /// Коллекция MongoDB + public IMongoCollection GetCollection(string collectionName = null) + { + if (_disposed) + throw new ObjectDisposedException(nameof(MongoContext)); + + var name = collectionName ?? typeof(T).Name.ToLowerInvariant(); + return Database.GetCollection(name); + } + + /// + /// Освобождает ресурсы + /// + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// + /// Освобождает ресурсы + /// + protected virtual void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + // Очищаем управляемые ресурсы + if (_mongoClient.IsValueCreated) + { + // MongoClient не требует явного Dispose, но если нужно, можно добавить + } + } + + _disposed = true; + } + } + + /// + /// Финализатор + /// + ~MongoContext() + { + Dispose(false); + } +} \ No newline at end of file diff --git a/src/Influunt.Storage/Services/ChannelService.cs b/src/Influunt.Storage/Services/ChannelService.cs index 5a4eb46..3715fe7 100644 --- a/src/Influunt.Storage/Services/ChannelService.cs +++ b/src/Influunt.Storage/Services/ChannelService.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using System.Threading; using Skidbladnir.Repository.Abstractions; namespace Influunt.Storage.Services; @@ -42,8 +43,9 @@ public Task Update(User user, FeedChannel channel) return _channelRepository.Update(channel); } - public async Task> GetUserChannels(User user) + public async Task> GetUserChannels(User user, CancellationToken cancellationToken = default) { - return await _channelRepository.GetAll().Where(c => c.UserId == user.Id).ToArrayAsync(); + cancellationToken.ThrowIfCancellationRequested(); + return await _channelRepository.GetAll().Where(c => c.UserId == user.Id).ToArrayAsync(cancellationToken); } -} \ No newline at end of file +} diff --git a/src/Influunt.Storage/Services/FeedService.cs b/src/Influunt.Storage/Services/FeedService.cs index d56c2c9..7040279 100644 --- a/src/Influunt.Storage/Services/FeedService.cs +++ b/src/Influunt.Storage/Services/FeedService.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using System.Threading; using Influunt.Feed; using Influunt.Feed.Entity; using Influunt.Feed.Services; @@ -71,20 +72,22 @@ public async Task RemoveFeedByChannel(User user, FeedChannel channel) _logger.LogInformation($"Removing channel with id = {channel.Id} and all channel feed completed"); } - public async Task TryAddToFeed(User user, IEnumerable newFeedItems, FeedChannel channel = null) + public async Task TryAddToFeed(User user, IEnumerable newFeedItems, FeedChannel channel = null, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); var addedCount = 0; foreach (var item in newFeedItems) { + cancellationToken.ThrowIfCancellationRequested(); item.UserId = user.Id; if (channel is not null) item.ChannelId = channel.Id; item.Hash = item.ComputeHash(); - if (await _feedRepository.GetAll().AnyAsync(x => x.UserId == user.Id && x.Hash == item.Hash)) + if (await _feedRepository.GetAll().AnyAsync(x => x.UserId == user.Id && x.Hash == item.Hash, cancellationToken)) continue; - await _feedRepository.Create(item); + await _feedRepository.Create(item, cancellationToken); addedCount++; } return addedCount; } -} \ No newline at end of file +} diff --git a/src/Influunt.Storage/Services/UserService.cs b/src/Influunt.Storage/Services/UserService.cs index 3b04898..490146e 100644 --- a/src/Influunt.Storage/Services/UserService.cs +++ b/src/Influunt.Storage/Services/UserService.cs @@ -5,6 +5,7 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; +using System.Threading; namespace Influunt.Storage.Services; @@ -19,9 +20,10 @@ public UserService(IRepository userRepository, IHttpContextAccessor httpCo _httpContextAccessor = httpContextAccessor; } - public async Task> GetUsers() + public async Task> GetUsers(CancellationToken cancellationToken = default) { - return await _userRepository.GetAll().ToArrayAsync(); + cancellationToken.ThrowIfCancellationRequested(); + return await _userRepository.GetAll().ToArrayAsync(cancellationToken); } public async Task GetCurrentUser() @@ -68,4 +70,4 @@ public Task Remove(User user) { return _userRepository.Delete(user); } -} \ No newline at end of file +} diff --git a/src/Influunt.Worker/Influunt.Worker.csproj b/src/Influunt.Worker/Influunt.Worker.csproj new file mode 100644 index 0000000..91fca51 --- /dev/null +++ b/src/Influunt.Worker/Influunt.Worker.csproj @@ -0,0 +1,20 @@ + + + net8 + enable + enable + f7b6a3ab-5dd3-487c-8763-0c114addb57b + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Influunt.Worker/NoOpHttpContextAccessor.cs b/src/Influunt.Worker/NoOpHttpContextAccessor.cs new file mode 100644 index 0000000..c0bea91 --- /dev/null +++ b/src/Influunt.Worker/NoOpHttpContextAccessor.cs @@ -0,0 +1,7 @@ +using Microsoft.AspNetCore.Http; + +public class NoOpHttpContextAccessor : IHttpContextAccessor +{ + public HttpContext? HttpContext { get => null; set => throw new NotImplementedException(); } + +} \ No newline at end of file diff --git a/src/Influunt.Worker/Program.cs b/src/Influunt.Worker/Program.cs new file mode 100644 index 0000000..d6c5ffe --- /dev/null +++ b/src/Influunt.Worker/Program.cs @@ -0,0 +1,22 @@ +using Influunt.Scheduler; +using Influunt.Storage; +using Influunt.Worker; +using Skidbladnir.Modules; + +var builder = Host.CreateApplicationBuilder(args); +if (builder.Environment.IsDevelopment()) + builder.Configuration.AddUserSecrets(); +builder.Services.AddOptions(); +builder.Services.AddJobSchedulerHost(c => +{ + builder.Configuration.GetSection("Scheduler").Bind(c); + c.ConnectionString = builder.Configuration.GetSection("ConnectionStrings:Mongo").GetValue("ConnectionString")!; +}); +builder.Services.AddSkidbladnirModules(configuration => +{ + var storageConfiguration = builder.Configuration.GetSection("ConnectionStrings:Mongo").Get(); + configuration.Add(storageConfiguration); +}, builder.Configuration); + +var host = builder.Build(); +host.Run(); diff --git a/src/Influunt.Worker/Properties/launchSettings.json b/src/Influunt.Worker/Properties/launchSettings.json new file mode 100644 index 0000000..c865ceb --- /dev/null +++ b/src/Influunt.Worker/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "Influunt.Worker": { + "commandName": "Project", + "dotnetRunMessages": true, + "environmentVariables": { + "DOTNET_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/Influunt.Worker/StartupModule.cs b/src/Influunt.Worker/StartupModule.cs new file mode 100644 index 0000000..965a0b8 --- /dev/null +++ b/src/Influunt.Worker/StartupModule.cs @@ -0,0 +1,33 @@ +using Influunt.Feed.Extensions; +using Influunt.Feed.Rss; +using Influunt.Storage; +using Microsoft.AspNetCore.Http; +using Skidbladnir.Caching.Distributed.MongoDB; +using Skidbladnir.Modules; + +namespace Influunt.Worker; + +public class StartupModule : Module +{ + public override Type[] DependsModules => [typeof(StorageModule), typeof(RssModule)]; + + public override void Configure(IServiceCollection services) + { + services.AddSingleton(); + ConfigureDistributedCache(services); + services.AddFeedJobs(); + } + + private void ConfigureDistributedCache(IServiceCollection services) + { + var redisConfiguration = Configuration.AppConfiguration["ConnectionStrings:Redis:ConnectionString"]; + if(string.IsNullOrWhiteSpace(redisConfiguration)) + { + services.AddMongoDistributedCache(Configuration.AppConfiguration["ConnectionStrings:Mongo:ConnectionString"]); + } + else + { + services.AddStackExchangeRedisCache(c => c.Configuration = redisConfiguration); + } + } +} \ No newline at end of file diff --git a/src/Influunt.Worker/appsettings.Development.json b/src/Influunt.Worker/appsettings.Development.json new file mode 100644 index 0000000..b2dcdb6 --- /dev/null +++ b/src/Influunt.Worker/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + } +} diff --git a/src/Influunt.Worker/appsettings.json b/src/Influunt.Worker/appsettings.json new file mode 100644 index 0000000..43cc794 --- /dev/null +++ b/src/Influunt.Worker/appsettings.json @@ -0,0 +1,22 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.Hosting.Lifetime": "Information" + } + }, + "ConnectionStrings": { + "Mongo": { + "ConnectionString": "" + }, + "Redis":{ + "ConnectionString": "" + } + }, + "Scheduler":{ + "ScanIntervalSeconds": 30, + "LockTimeoutSeconds": 600, + "HeartbeatIntervalSeconds": 60, + "MaxConcurrentJobs": 10 + } +} diff --git a/tests/Influunt.Feed.Tests/FeedJobsTests.cs b/tests/Influunt.Feed.Tests/FeedJobsTests.cs new file mode 100644 index 0000000..f0dcdbb --- /dev/null +++ b/tests/Influunt.Feed.Tests/FeedJobsTests.cs @@ -0,0 +1,226 @@ +using FakeItEasy; +using Influunt.Feed.Crawler; +using Influunt.Feed.Entity; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; + +namespace Influunt.Feed.Tests; + +[Trait("Category", "Unit")] +public sealed class FeedJobsTests +{ + [Fact] + public async Task Crawler_Only_Schedules_Users_Strictly_Inside_Activity_Window() + { + var h = new CrawlerHarness(); + var cutoff = h.Now.AddDays(-93); + h.Users.AddRange(new[] { new User { Id = "old", LastActivity = cutoff.AddTicks(-1) }, + new User { Id = "boundary", LastActivity = cutoff }, new User { Id = "active", LastActivity = cutoff.AddTicks(1) } }); + await h.Job.ExecuteAsync(default); + Assert.Equal("active", Assert.Single(h.Enqueued).Id); + } + + [Fact] + public async Task Crawler_With_No_Users_Does_Not_Enqueue_Work() + { + var h = new CrawlerHarness(); + await h.Job.ExecuteAsync(default); + Assert.Empty(h.Enqueued); + } + + [Fact] + public async Task Crawler_Continues_After_Enqueue_Failure_But_Reports_An_Aggregated_Error() + { + var h = new CrawlerHarness(); + h.Users.AddRange(new[] { new User { Id = "a", LastActivity = h.Now }, new User { Id = "b", LastActivity = h.Now } }); + A.CallTo(() => h.Scheduler.ScheduleOneTimeJobAsync( + A.That.Matches(x => x.User.Id == "a"), null, null, A._)) + .ThrowsAsync(new InvalidOperationException("enqueue failed")); + var error = await Assert.ThrowsAsync(() => h.Job.ExecuteAsync(default)); + Assert.Contains("enqueue failed", Assert.Single(error.InnerExceptions).Message); + Assert.Equal("b", Assert.Single(h.Enqueued).Id); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Crawler_Cancellation_Stops_Enqueueing(bool cancelAfterFirst) + { + var h = new CrawlerHarness(); + h.Users.AddRange(new[] { new User { Id = "a", LastActivity = h.Now }, new User { Id = "b", LastActivity = h.Now } }); + using var cts = new CancellationTokenSource(); + if (cancelAfterFirst) + A.CallTo(() => h.Scheduler.ScheduleOneTimeJobAsync( + A._, null, null, cts.Token)) + .ReturnsLazily(() => { cts.Cancel(); return Task.FromResult("first"); }); + else + cts.Cancel(); + await Assert.ThrowsAnyAsync(() => h.Job.ExecuteAsync(cts.Token)); + A.CallTo(() => h.Scheduler.ScheduleOneTimeJobAsync( + A._, null, null, cts.Token)) + .MustHaveHappened(cancelAfterFirst ? 1 : 0, Times.Exactly); + } + + [Fact] + public async Task Cancellation_During_Enqueue_Is_Not_Converted_To_A_Partial_Failure() + { + var h = new CrawlerHarness(); + h.Users.Add(new User { Id = "a", LastActivity = h.Now }); + using var cts = new CancellationTokenSource(); + A.CallTo(() => h.Scheduler.ScheduleOneTimeJobAsync( + A._, null, null, cts.Token)) + .ReturnsLazily(() => { cts.Cancel(); return Task.FromCanceled(cts.Token); }); + await Assert.ThrowsAnyAsync(() => h.Job.ExecuteAsync(cts.Token)); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Missing_User_Does_Not_Load_Channels(bool nullSettings) + { + var h = new ChannelHarness(); + await h.Job.ExecuteAsync(nullSettings ? null! : new UserFeedChannelUpdateJobSettings(), default); + A.CallTo(() => h.Channels.GetUserChannels(A._, A._)).MustNotHaveHappened(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Empty_Or_Null_Channels_Do_Not_Fetch_Feeds(bool nullChannels) + { + var h = new ChannelHarness(0); + if (nullChannels) + A.CallTo(() => h.Channels.GetUserChannels(h.User, A._)).Returns(Task.FromResult>(null!)); + await h.Execute(); + A.CallTo(() => h.Source.GetRemoteFeed(A._, A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task Unsupported_Channels_Are_Skipped() + { + var h = new ChannelHarness(); + A.CallTo(() => h.Source.CanProcessChannel(A._)).Returns(false); + await h.Execute(); + A.CallTo(() => h.Source.GetRemoteFeed(A._, A._)).MustNotHaveHappened(); + A.CallTo(() => h.Feed.TryAddToFeed(A._, A>._, A._, A._)).MustNotHaveHappened(); + } + + [Fact] + public async Task At_Most_Five_Channels_Are_Fetched_Concurrently() + { + var h = new ChannelHarness(12); + var reachedFive = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var active = 0; + var started = 0; + var peak = 0; + A.CallTo(() => h.Source.GetRemoteFeed(A._, A._)) + .ReturnsLazily(async (FeedChannel _, CancellationToken token) => + { + var count = Interlocked.Increment(ref active); + peak = Math.Max(peak, count); + if (Interlocked.Increment(ref started) == 5) reachedFive.TrySetResult(); + try { await release.Task.WaitAsync(token); return (IEnumerable)Array.Empty(); } + finally { Interlocked.Decrement(ref active); } + }); + var execution = h.Execute(); + try + { + await reachedFive.Task.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(5, started); + } + finally { release.TrySetResult(); } + await execution; + Assert.Equal(12, started); + Assert.Equal(5, peak); + } + + [Fact] + public async Task Cancellation_Stops_Waiting_Channels_And_Propagates_To_Active_Fetches() + { + var h = new ChannelHarness(12); + using var cts = new CancellationTokenSource(); + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var started = 0; + A.CallTo(() => h.Source.GetRemoteFeed(A._, cts.Token)) + .ReturnsLazily(async (FeedChannel _, CancellationToken token) => + { + if (Interlocked.Increment(ref started) == 5) reached.TrySetResult(); + await Task.Delay(Timeout.Infinite, token); + return (IEnumerable)Array.Empty(); + }); + var execution = h.Execute(cts.Token); + try { await reached.Task.WaitAsync(TimeSpan.FromSeconds(5)); } + finally { cts.Cancel(); } + await Assert.ThrowsAnyAsync(() => execution); + Assert.Equal(5, started); + A.CallTo(() => h.Feed.TryAddToFeed(A._, A>._, A._, A._)).MustNotHaveHappened(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task A_Channel_Failure_Does_Not_Hide_The_Error_Or_Prevent_Other_Channels(bool storageFailure) + { + var h = new ChannelHarness(12); + if (storageFailure) + A.CallTo(() => h.Feed.TryAddToFeed(h.User, A>._, + A.That.Matches(x => x.Id == "0"), A._)) + .ThrowsAsync(new InvalidOperationException("storage failure")); + else + A.CallTo(() => h.Source.GetRemoteFeed(A.That.Matches(x => x.Id == "0"), A._)) + .ThrowsAsync(new InvalidOperationException("source failure")); + await Assert.ThrowsAsync(() => h.Execute()); + A.CallTo(() => h.Source.GetRemoteFeed(A._, A._)).MustHaveHappened(12, Times.Exactly); + A.CallTo(() => h.Feed.TryAddToFeed(h.User, A>._, A._, A._)) + .MustHaveHappened(storageFailure ? 12 : 11, Times.Exactly); + } + + [Fact] + public async Task Channel_Lookup_Error_Reaches_The_Scheduler() + { + var h = new ChannelHarness(); + A.CallTo(() => h.Channels.GetUserChannels(h.User, A._)).ThrowsAsync(new InvalidOperationException("lookup failure")); + await Assert.ThrowsAsync(() => h.Execute()); + } + + private sealed class CrawlerHarness + { + public DateTime Now { get; } = new(2030, 1, 1, 12, 0, 0, DateTimeKind.Utc); + public List Users { get; } = new(); + public List Enqueued { get; } = new(); + public IScheduler Scheduler { get; } = A.Fake(); + public FeedCrawlerJob Job { get; } + public CrawlerHarness() + { + var users = A.Fake(); + A.CallTo(() => users.GetUsers(A._)).ReturnsLazily(() => Task.FromResult>(Users)); + A.CallTo(() => Scheduler.ScheduleOneTimeJobAsync( + A._, null, null, A._)) + .ReturnsLazily((UserFeedChannelUpdateJobSettings settings, DateTime? _, string? _, CancellationToken _) => + { Enqueued.Add(settings.User); return Task.FromResult(Guid.NewGuid().ToString()); }); + Job = new FeedCrawlerJob(Options.Create(new CrawlerOptions()), users, Scheduler, NullLogger.Instance, new FixedClock(Now)); + } + } + + private sealed class FixedClock(DateTime now) : TimeProvider { public override DateTimeOffset GetUtcNow() => new(now); } + + private sealed class ChannelHarness + { + public User User { get; } = new() { Id = "user", Email = "user@example.test" }; + public IChannelService Channels { get; } = A.Fake(); + public IFeedSourceProvider Source { get; } = A.Fake(); + public IFeedService Feed { get; } = A.Fake(); + public UserFeedChannelUpdateJob Job { get; } + public ChannelHarness(int count = 1) + { + A.CallTo(() => Channels.GetUserChannels(User, A._)) + .Returns(Task.FromResult>(Enumerable.Range(0, count).Select(i => new FeedChannel { Id = i.ToString() }).ToArray())); + A.CallTo(() => Source.CanProcessChannel(A._)).Returns(true); + A.CallTo(() => Source.GetRemoteFeed(A._, A._)).Returns(Task.FromResult>(Array.Empty())); + Job = new UserFeedChannelUpdateJob(new[] { Source }, Feed, Channels, NullLogger.Instance); + } + public Task Execute(CancellationToken token = default) => Job.ExecuteAsync(new() { User = User }, token); + } +} diff --git a/tests/Influunt.Feed.Tests/Influunt.Feed.Tests.csproj b/tests/Influunt.Feed.Tests/Influunt.Feed.Tests.csproj new file mode 100644 index 0000000..47e7e13 --- /dev/null +++ b/tests/Influunt.Feed.Tests/Influunt.Feed.Tests.csproj @@ -0,0 +1,20 @@ + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + diff --git a/tests/Influunt.Feed.Tests/RssCancellationTests.cs b/tests/Influunt.Feed.Tests/RssCancellationTests.cs new file mode 100644 index 0000000..ccb09fb --- /dev/null +++ b/tests/Influunt.Feed.Tests/RssCancellationTests.cs @@ -0,0 +1,96 @@ +using System.Net; +using FakeItEasy; +using Influunt.Feed.Entity; +using Influunt.Feed.Rss; +using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Influunt.Feed.Tests; + +[Trait("Category", "Unit")] +public sealed class RssCancellationTests +{ + private static readonly FeedChannel Channel = new() { Id = "channel", Url = "https://example.test/rss" }; + + [Fact] + public async Task Cancelling_An_Active_Http_Request_Is_Not_Reported_As_An_Empty_Feed() + { + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using var http = new HttpClient(new Handler(async (_, token) => + { + reached.TrySetResult(); + await Task.Delay(Timeout.Infinite, token); + return new HttpResponseMessage(HttpStatusCode.OK); + })); + using var cts = new CancellationTokenSource(); + var client = new RssClient(http, NullLogger.Instance); + var fetching = client.GetFeed(Channel, cts.Token); + try { await reached.Task.WaitAsync(TimeSpan.FromSeconds(5)); } + finally { cts.Cancel(); } + await Assert.ThrowsAnyAsync(() => fetching); + } + + [Theory] + [InlineData(HttpStatusCode.Forbidden)] + [InlineData(HttpStatusCode.InternalServerError)] + public async Task Http_Failure_Is_Propagated(HttpStatusCode status) + { + using var http = new HttpClient(new Handler((_, _) => Task.FromResult(new HttpResponseMessage(status)))); + var client = new RssClient(http, NullLogger.Instance); + var error = await Assert.ThrowsAsync(() => client.GetFeed(Channel)); + Assert.Equal(status, error.StatusCode); + } + + [Fact] + public async Task Invalid_Feed_Is_A_Failure_Not_A_Successful_Empty_Result() + { + using var http = new HttpClient(new Handler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent("invalid xml") }))); + var client = new RssClient(http, NullLogger.Instance); + await Assert.ThrowsAsync(() => client.GetFeed(Channel)); + } + + [Fact] + public async Task Valid_Rss_Is_Still_Parsed() + { + const string xml = "testposttexthttps://example.test/post"; + using var http = new HttpClient(new Handler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent(xml) }))); + var client = new RssClient(http, NullLogger.Instance); + Assert.Equal("post", Assert.Single(await client.GetFeed(Channel)).Title); + } + + [Theory] + [InlineData("empty")] + [InlineData("empty")] + public async Task Valid_Empty_Rss_And_Atom_Are_Not_Reported_As_Errors(string xml) + { + using var http = new HttpClient(new Handler((_, _) => Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { Content = new StringContent(xml) }))); + var client = new RssClient(http, NullLogger.Instance); + Assert.Empty(await client.GetFeed(Channel)); + } + + [Fact] + public async Task Cache_Read_Receives_The_Cancellation_Token() + { + var cache = A.Fake(); + using var cts = new CancellationTokenSource(); + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + A.CallTo(() => cache.GetAsync(A._, cts.Token)).ReturnsLazily(async () => + { + reached.TrySetResult(); + await Task.Delay(Timeout.Infinite, cts.Token); + return (byte[]?)Array.Empty(); + }); + using var http = new HttpClient(new Handler((_, _) => throw new InvalidOperationException("HTTP should not start"))); + using var provider = new RssFeedSourceProvider(new RssClient(http, NullLogger.Instance), cache, NullLogger.Instance); + var fetching = provider.GetRemoteFeed(Channel, cts.Token); + try { await reached.Task.WaitAsync(TimeSpan.FromSeconds(5)); } + finally { cts.Cancel(); } + await Assert.ThrowsAnyAsync(() => fetching); + } + + private sealed class Handler(Func> send) : HttpMessageHandler + { protected override Task SendAsync(HttpRequestMessage request, CancellationToken token) => send(request, token); } +} diff --git a/tests/Influunt.Feed.Tests/StorageCancellationTests.cs b/tests/Influunt.Feed.Tests/StorageCancellationTests.cs new file mode 100644 index 0000000..928d24f --- /dev/null +++ b/tests/Influunt.Feed.Tests/StorageCancellationTests.cs @@ -0,0 +1,42 @@ +using FakeItEasy; +using Influunt.Feed.Entity; +using Influunt.Storage.Services; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using Skidbladnir.Repository.Abstractions; + +namespace Influunt.Feed.Tests; + +[Trait("Category", "Unit")] +public sealed class StorageCancellationTests +{ + [Fact] + public async Task Cancelled_User_Lookup_Does_Not_Start_A_Query() + { + var repository = A.Fake>(); + var service = new UserService(repository, A.Fake()); + await Assert.ThrowsAnyAsync(() => service.GetUsers(new CancellationToken(true))); + A.CallTo(() => repository.GetAll()).MustNotHaveHappened(); + } + + [Fact] + public async Task Cancelled_Channel_Lookup_Does_Not_Start_A_Query() + { + var repository = A.Fake>(); + var service = new ChannelService(repository); + await Assert.ThrowsAnyAsync(() => service.GetUserChannels(new User(), new CancellationToken(true))); + A.CallTo(() => repository.GetAll()).MustNotHaveHappened(); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Cancelled_Feed_Write_Does_Not_Query_Or_Create_Items(bool emptyFeed) + { + var repository = A.Fake>(); + var service = new FeedService(A.Fake(), repository, NullLogger.Instance); + await Assert.ThrowsAnyAsync(() => service.TryAddToFeed(new User(), + emptyFeed ? Array.Empty() : new[] { new FeedItem() }, cancellationToken: new CancellationToken(true))); + A.CallTo(() => repository.GetAll()).MustNotHaveHappened(); + } +} diff --git a/tests/Influunt.Scheduler.Tests/CronHelperTests.cs b/tests/Influunt.Scheduler.Tests/CronHelperTests.cs new file mode 100644 index 0000000..7772c5b --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/CronHelperTests.cs @@ -0,0 +1,37 @@ +using Cronos; + +namespace Influunt.Scheduler.Tests; + +[Trait("Category", "Unit")] +public sealed class CronHelperTests +{ + [Theory] + [InlineData("* * * * *", "2030-01-01T12:00:00Z", "2030-01-01T12:01:00Z")] + [InlineData("* * * * *", "2030-01-01T12:00:59Z", "2030-01-01T12:01:00Z")] + [InlineData("0 0 * * *", "2030-12-31T23:59:59Z", "2031-01-01T00:00:00Z")] + [InlineData("0 0 29 2 *", "2023-03-01T00:00:00Z", "2024-02-29T00:00:00Z")] + [InlineData("*/30 * * * *", "2030-01-01T12:30:00Z", "2030-01-01T13:00:00Z")] + public void Next_Occurrence_Is_Strictly_After_Input_And_In_Utc(string cron, string from, string expected) + { + var next = CronHelper.GetNextOccurrence(cron, DateTimeOffset.Parse(from).UtcDateTime); + Assert.Equal(DateTimeOffset.Parse(expected).UtcDateTime, next); + Assert.Equal(DateTimeKind.Utc, next!.Value.Kind); + } + + [Theory] + [InlineData("")] + [InlineData("invalid")] + [InlineData("* * * * * *")] + [InlineData("60 * * * *")] + [InlineData("*/0 * * * *")] + public void Invalid_And_Unsupported_Cron_Is_Rejected(string cron) => + Assert.Throws(() => CronHelper.GetNextOccurrence(cron, DateTime.UtcNow)); + + [Fact] + public void Impossible_Calendar_Date_Has_No_Next_Occurrence() => + Assert.Null(CronHelper.GetNextOccurrence("0 0 30 2 *", new DateTime(2030, 1, 1, 0, 0, 0, DateTimeKind.Utc))); + + [Fact] + public void Non_Utc_Input_Is_Rejected() => + Assert.Throws(() => CronHelper.GetNextOccurrence("* * * * *", new DateTime(2030, 1, 1))); +} diff --git a/tests/Influunt.Scheduler.Tests/Influunt.Scheduler.Tests.csproj b/tests/Influunt.Scheduler.Tests/Influunt.Scheduler.Tests.csproj new file mode 100644 index 0000000..a7c2629 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/Influunt.Scheduler.Tests.csproj @@ -0,0 +1,32 @@ + + + net8.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs b/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs new file mode 100644 index 0000000..02d7fb7 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/JobSchedulerIntegrationTests.cs @@ -0,0 +1,334 @@ +using AwesomeAssertions; +using Influunt.Scheduler; +using Influunt.Scheduler.Abstractions.Models; +using Influunt.Scheduler.Abstractions.Services; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; + +namespace Influunt.Scheduler.Tests; + +[Trait("Category", "Integration")] +public class JobSchedulerIntegrationTests : SchedulerIntegrationTestBase +{ + protected override void ConfigureServices(IServiceCollection services, string connectionString) + { + // Register test jobs + services.AddTransient(); + services.AddTransient(); + services.AddTransient(); + + // Add scheduler module with hosted service registration + services.AddJobSchedulerHost(options => + { + options.ConnectionString = connectionString; + options.ScanIntervalSeconds = 1; // Faster scanning for tests + options.LockTimeoutSeconds = 5; + options.HeartbeatIntervalSeconds = 2; + options.MaxConcurrentJobs = 10; + }); + } + + protected override async Task InitializeTestDataAsync() + { + // Reset static fields + TestJobWithoutSettings.WasExecuted = false; + TestJobWithoutSettings.ExecutionCount = 0; + TestJobWithSettings.WasExecuted = false; + TestJobWithSettings.ExecutionCount = 0; + TestJobWithSettings.LastSettings = null; + TestBlockingJob.ExecutionCount = 0; + TestBlockingJob.ContinueExecution = null; + + await base.InitializeTestDataAsync(); + } + + [Fact] + public async Task Should_Register_Job_Without_Settings() + { + // Arrange + var scheduler = GetRequiredService(); + + // Act + await scheduler.RegisterJobAsync("TestJobWithoutSettings", "Test job without settings"); + + // Assert + var jobs = await scheduler.GetRegisteredJobsAsync(); + jobs.Should().ContainSingle(); + var job = jobs.First(); + job.Id.Should().NotBeNullOrEmpty(); + job.Name.Should().Be("TestJobWithoutSettings"); + job.Description.Should().Be("Test job without settings"); + job.JobType.Should().Be(typeof(TestJobWithoutSettings).AssemblyQualifiedName); + job.SettingsType.Should().BeNullOrEmpty(); + } + + [Fact] + public async Task Should_Register_Job_With_Settings() + { + // Arrange + var scheduler = GetRequiredService(); + + // Act + await scheduler.RegisterJobAsync("TestJobWithSettings", "Test job with settings"); + + // Assert + var jobs = await scheduler.GetRegisteredJobsAsync(); + jobs.Should().ContainSingle(); + var job = jobs.First(); + job.Id.Should().NotBeNullOrEmpty(); + job.Name.Should().Be("TestJobWithSettings"); + job.Description.Should().Be("Test job with settings"); + job.JobType.Should().Be(typeof(TestJobWithSettings).AssemblyQualifiedName); + job.SettingsType.Should().Be(typeof(TestJobSettings).AssemblyQualifiedName); + } + + [Fact] + public async Task Should_Schedule_OneTime_Job_Without_Settings() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings"); + + // Act + var triggerId = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1)); + + // Assert + triggerId.Should().NotBeNullOrEmpty(); + + var triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().ContainSingle(); + var trigger = triggers.First(); + trigger.Id.Should().NotBeNullOrEmpty(); + trigger.TriggerId.Should().Be(triggerId); + trigger.JobType.Should().Be(typeof(TestJobWithoutSettings).AssemblyQualifiedName); + trigger.IsRecurring.Should().BeFalse(); + + // Wait for job to execute + await Task.Delay(3000); + + TestJobWithoutSettings.WasExecuted.Should().BeTrue(); + + // Trigger should be removed after execution + triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().BeEmpty(); + } + + [Fact] + public async Task Should_Schedule_Recurring_Job_Without_Settings() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings"); + + // Act + var triggerId = await scheduler.ScheduleRecurringJobAsync("* * * * *"); // Every minute + + // Assert + triggerId.Should().NotBeNullOrEmpty(); + + var triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().ContainSingle(); + var trigger = triggers.First(); + trigger.Id.Should().NotBeNullOrEmpty(); + trigger.TriggerId.Should().Be(triggerId); + trigger.JobType.Should().Be(typeof(TestJobWithoutSettings).AssemblyQualifiedName); + trigger.IsRecurring.Should().BeTrue(); + trigger.CronExpression.Should().Be("* * * * *"); + + await Task.Delay(TimeSpan.FromSeconds(70)); // Wait 70 seconds to ensure job runs (around next minute) + + TestJobWithoutSettings.WasExecuted.Should().BeTrue(); + + // Trigger should still exist + triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().ContainSingle(); + } + + [Fact] + public async Task Should_Execute_Recurring_Job_Multiple_Times() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings"); + + var triggerId = await scheduler.ScheduleRecurringJobAsync("* * * * *"); + + // Force the trigger to be due immediately + var context = GetRequiredService(); + var triggersCollection = context.GetCollection(Constants.JobTriggersCollectionName); + await ForceRunTrigger(triggerId, triggersCollection); + + // Wait for first execution to complete + await Task.Delay(TimeSpan.FromSeconds(3)); + + TestJobWithoutSettings.ExecutionCount.Should().BeGreaterThanOrEqualTo(1); + + // Force trigger to be due again for second execution + await ForceRunTrigger(triggerId, triggersCollection); + + // Wait for second scan cycle + await Task.Delay(TimeSpan.FromSeconds(3)); + + // Verify job ran at least twice + TestJobWithoutSettings.ExecutionCount.Should().BeGreaterThanOrEqualTo(2); + + // Trigger should still exist for recurring job + var remainingTriggers = await scheduler.GetScheduledTriggersAsync(); + remainingTriggers.Should().ContainSingle(); + } + + [Fact] + public async Task Should_Schedule_OneTime_Job_With_Settings() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithSettings"); + + var settings = new TestJobSettings + { + Message = "Hello Test", + Count = 42 + }; + + // Act + var triggerId = await scheduler.ScheduleOneTimeJobAsync( + settings, DateTime.UtcNow.AddSeconds(1)); + + // Assert + triggerId.Should().NotBeNullOrEmpty(); + + // Wait for job to execute + await Task.Delay(TimeSpan.FromSeconds(3)); + + TestJobWithSettings.WasExecuted.Should().BeTrue(); + TestJobWithSettings.LastSettings.Should().NotBeNull(); + TestJobWithSettings.LastSettings!.Message.Should().Be("Hello Test"); + TestJobWithSettings.LastSettings!.Count.Should().Be(42); + + // Trigger should be removed after execution + var triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().BeEmpty(); + } + + [Fact] + public async Task Should_Unschedule_Job() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings"); + var triggerId = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddMinutes(5)); + + // Act + await scheduler.UnScheduleJobAsync(triggerId); + + // Assert + var triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().BeEmpty(); + } + + [Fact] + public async Task Should_Not_Allow_Duplicate_Trigger_Execution() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings", allowConcurrentExecution: false); + + var runAt = DateTime.UtcNow.AddSeconds(1); + await scheduler.ScheduleOneTimeJobAsync(runAt, "test-trigger"); + var duplicateTriggerId = () => scheduler.ScheduleOneTimeJobAsync(runAt.AddSeconds(10), "test-trigger"); + + var exception = await Assert.ThrowsAsync(duplicateTriggerId); + exception.WriteError.Category.Should().Be(ServerErrorCategory.DuplicateKey); + exception.WriteError.Code.Should().Be(11000); + } + + [Fact] + public async Task Should_Handle_Multiple_Concurrent_Jobs() + { + // Arrange + var scheduler = GetRequiredService(); + await scheduler.RegisterJobAsync("TestJobWithoutSettings", allowConcurrentExecution: true); + + // Act + var triggerId1 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "trigger1"); + var triggerId2 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "trigger2"); + var triggerId3 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "trigger3"); + + // Assert + var triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().HaveCount(3); + + // Wait for execution + await Task.Delay(TimeSpan.FromSeconds(4)); + + TestJobWithoutSettings.WasExecuted.Should().BeTrue(); + TestJobWithoutSettings.ExecutionCount.Should().Be(3); + + // All triggers should be removed after execution + triggers = await scheduler.GetScheduledTriggersAsync(); + triggers.Should().BeEmpty(); + } + + [Fact] + public async Task Should_Respect_AllowConcurrentExecution_False() + { + // Arrange + var scheduler = GetRequiredService(); + + TestBlockingJob.ExecutionCount = 0; + var blockTcs = new TaskCompletionSource(); + TestBlockingJob.ContinueExecution = blockTcs; + + await scheduler.RegisterJobAsync("TestBlocking", allowConcurrentExecution: false); + + var triggerId1 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "concurrent-block-a"); + var triggerId2 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "concurrent-block-b"); + + // Act — wait for scan cycle + await Task.Delay(3000); + + // Assert — only one trigger should execute (second is blocked by AllowConcurrentExecution) + TestBlockingJob.ExecutionCount.Should().Be(1); + + // Release the first job + blockTcs.SetResult(); + TestBlockingJob.ContinueExecution = null; + + await Task.Delay(3000); + + // Second trigger should now execute + TestBlockingJob.ExecutionCount.Should().Be(2); + } + + [Fact] + public async Task Should_Execute_Concurrent_Jobs_With_AllowConcurrentExecution_True() + { + // Arrange + var scheduler = GetRequiredService(); + + TestBlockingJob.ExecutionCount = 0; + var blockTcs = new TaskCompletionSource(); + TestBlockingJob.ContinueExecution = blockTcs; + + await scheduler.RegisterJobAsync("TestBlockingConcurrent", allowConcurrentExecution: true); + + var triggerId1 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "concurrent-allow-a"); + var triggerId2 = await scheduler.ScheduleOneTimeJobAsync(DateTime.UtcNow.AddSeconds(1), "concurrent-allow-b"); + + // Act — wait for scan cycle + await Task.Delay(3000); + + // Assert — both triggers should execute (AllowConcurrentExecution = true) + TestBlockingJob.ExecutionCount.Should().Be(2); + + blockTcs.SetResult(); + TestBlockingJob.ContinueExecution = null; + } + + private static async Task ForceRunTrigger(string triggerId, IMongoCollection triggersCollection) + { + await triggersCollection.UpdateOneAsync( + t => t.TriggerId == triggerId, + Builders.Update.Set(t => t.NextRunTime, DateTime.UtcNow.AddSeconds(-5))); + } +} diff --git a/tests/Influunt.Scheduler.Tests/JobSchedulerUnitTests.cs b/tests/Influunt.Scheduler.Tests/JobSchedulerUnitTests.cs new file mode 100644 index 0000000..1cad8c2 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/JobSchedulerUnitTests.cs @@ -0,0 +1,199 @@ +using System.Text.Json; +using Cronos; +using FakeItEasy; +using Influunt.Scheduler.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MongoDB.Driver; + +namespace Influunt.Scheduler.Tests; + +[Trait("Category", "Unit")] +public sealed class JobSchedulerUnitTests +{ + [Theory] + [InlineData("scan", 0)] + [InlineData("scan", -1)] + [InlineData("batch", 0)] + [InlineData("batch", -1)] + [InlineData("workers", 0)] + [InlineData("workers", -1)] + [InlineData("heartbeat", 0)] + [InlineData("heartbeat", -1)] + [InlineData("lease", 0)] + [InlineData("lease", -1)] + [InlineData("lease", 60)] + public void Invalid_Options_Are_Rejected(string field, int value) + { + var options = new JobSchedulerOptions(); + switch (field) + { + case "scan": options.ScanIntervalSeconds = value; break; + case "batch": options.ScanBatchSize = value; break; + case "workers": options.MaxConcurrentJobs = value; break; + case "heartbeat": options.HeartbeatIntervalSeconds = value; break; + case "lease": options.LockTimeoutSeconds = value; break; + } + using var provider = new ServiceCollection().BuildServiceProvider(); + Assert.Throws(() => new JobScheduler(provider, Options.Create(options), + NullLogger.Instance, A.Fake())); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(2)] + [InlineData(3)] + public async Task Every_Scheduling_Overload_Requires_A_Registered_Job(int overload) + { + using var h = new UnitHarness(); + await Assert.ThrowsAsync(() => overload switch + { + 0 => h.Node.ScheduleOneTimeJobAsync(), + 1 => h.Node.ScheduleRecurringJobAsync("* * * * *"), + 2 => h.Node.ScheduleOneTimeJobAsync(new()), + _ => h.Node.ScheduleRecurringJobAsync("* * * * *", new()) + }); + Assert.Empty(h.Inserted); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Settings_Type_Mismatch_Is_Rejected_Before_Inserting_Trigger(bool recurring) + { + using var h = new UnitHarness(new JobDefinition + { + JobType = typeof(SettingsJob).AssemblyQualifiedName!, SettingsType = typeof(string).AssemblyQualifiedName! + }); + await Assert.ThrowsAsync(() => recurring + ? h.Node.ScheduleRecurringJobAsync("* * * * *", new()) + : h.Node.ScheduleOneTimeJobAsync(new())); + Assert.Empty(h.Inserted); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Impossible_Cron_Is_Rejected_Without_Inserting_Trigger(bool withSettings) + { + using var h = new UnitHarness(Definition(withSettings)); + await Assert.ThrowsAsync(() => withSettings + ? h.Node.ScheduleRecurringJobAsync("0 0 30 2 *", new()) + : h.Node.ScheduleRecurringJobAsync("0 0 30 2 *")); + Assert.Empty(h.Inserted); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Invalid_Cron_Is_Rejected_Without_Inserting_Trigger(bool withSettings) + { + using var h = new UnitHarness(Definition(withSettings)); + await Assert.ThrowsAsync(() => withSettings + ? h.Node.ScheduleRecurringJobAsync("invalid", new()) + : h.Node.ScheduleRecurringJobAsync("invalid")); + Assert.Empty(h.Inserted); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public async Task Generated_Trigger_Ids_Are_Unique_And_Immediate_Run_Uses_Clock(string? id) + { + using var h = new UnitHarness(Definition(false)); + var a = await h.Node.ScheduleOneTimeJobAsync(triggerId: id); + var b = await h.Node.ScheduleOneTimeJobAsync(triggerId: id); + Assert.True(Guid.TryParse(a, out _)); + Assert.NotEqual(a, b); + Assert.All(h.Inserted, t => Assert.Equal(h.Clock.GetUtcNow().UtcDateTime, t.NextRunTime)); + Assert.All(h.Inserted, t => Assert.Equal(h.Clock.GetUtcNow().UtcDateTime, t.CreatedAt)); + } + + [Fact] + public async Task Local_Run_Time_Is_Converted_To_Utc_And_Explicit_Id_Is_Preserved() + { + using var h = new UnitHarness(Definition(false)); + var local = new DateTime(2030, 1, 1, 12, 0, 0, DateTimeKind.Local); + await h.Node.ScheduleOneTimeJobAsync(local, "explicit"); + var trigger = Assert.Single(h.Inserted); + Assert.Equal(local.ToUniversalTime(), trigger.NextRunTime); + Assert.Equal(DateTimeKind.Utc, trigger.NextRunTime.Kind); + Assert.Equal("explicit", trigger.TriggerId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Settings_Round_Trip_And_Recurring_Schedule_Use_The_Same_Clock(bool recurring) + { + using var h = new UnitHarness(Definition(true)); + var settings = new Payload { Text = "RSS: новости <>&", Values = new[] { 0, -1, int.MaxValue } }; + if (recurring) + await h.Node.ScheduleRecurringJobAsync("*/30 * * * *", settings, "settings"); + else + await h.Node.ScheduleOneTimeJobAsync(settings, triggerId: "settings"); + var trigger = Assert.Single(h.Inserted); + var decoded = JsonSerializer.Deserialize(trigger.SettingsJson!)!; + Assert.Equal(settings.Text, decoded.Text); + Assert.Equal(settings.Values, decoded.Values); + Assert.Equal(h.Clock.GetUtcNow().UtcDateTime.AddMinutes(recurring ? 30 : 0), trigger.NextRunTime); + } + + [Fact] + public async Task Stop_And_Dispose_Are_Idempotent_And_Prevent_New_Scans() + { + using var h = new UnitHarness(); + await h.Node.StopAsync(new CancellationToken(true)); + await h.Node.StopAsync(default); + h.Node.Dispose(); + h.Node.Dispose(); + await h.Node.ScanTriggersAsync(); + await h.Node.HeartbeatAsync(); + Assert.False(await h.Node.ScheduleJobAsync(new JobTrigger())); + Assert.Empty(h.Inserted); + } + + private static JobDefinition Definition(bool settings) => new() + { + JobType = (settings ? typeof(SettingsJob) : typeof(PlainJob)).AssemblyQualifiedName!, + SettingsType = settings ? typeof(Payload).AssemblyQualifiedName! : null! + }; + + private sealed class UnitHarness : IDisposable + { + private readonly ServiceProvider _provider = new ServiceCollection().BuildServiceProvider(); + public ManualTimeProvider Clock { get; } = new(); + public List Inserted { get; } = new(); + public JobScheduler Node { get; } + public UnitHarness(JobDefinition? definition = null) + { + var context = A.Fake(); + var definitions = A.Fake>(); + var triggers = A.Fake>(); + A.CallTo(() => context.GetCollection(Constants.JobDefinitionsCollectionName)).Returns(definitions); + A.CallTo(() => context.GetCollection(Constants.JobTriggersCollectionName)).Returns(triggers); + A.CallTo(() => definitions.FindAsync(A>._, A>._, A._)) + .ReturnsLazily(() => Task.FromResult>(new SingleBatchCursor(definition == null ? Array.Empty() : new[] { definition }))); + A.CallTo(() => triggers.InsertOneAsync(A._, A._, A._)) + .Invokes((JobTrigger trigger, InsertOneOptions _, CancellationToken _) => Inserted.Add(trigger)) + .Returns(Task.CompletedTask); + Node = new JobScheduler(_provider, Options.Create(new JobSchedulerOptions()), NullLogger.Instance, context, Clock); + } + public void Dispose() { Node.Dispose(); _provider.Dispose(); } + } + + private sealed class SingleBatchCursor(IEnumerable items) : IAsyncCursor + { + private bool _moved; + public IEnumerable Current => items; + public bool MoveNext(CancellationToken cancellationToken = default) { var first = !_moved; _moved = true; return first; } + public Task MoveNextAsync(CancellationToken cancellationToken = default) => Task.FromResult(MoveNext(cancellationToken)); + public void Dispose() { } + } + + public sealed class PlainJob : IJob { public Task ExecuteAsync(CancellationToken cancellationToken) => Task.CompletedTask; } + public sealed class Payload { public string? Text { get; set; } public int[]? Values { get; set; } } + public sealed class SettingsJob : IJob { public Task ExecuteAsync(Payload settings, CancellationToken cancellationToken) => Task.CompletedTask; } +} diff --git a/tests/Influunt.Scheduler.Tests/ManualTimeProvider.cs b/tests/Influunt.Scheduler.Tests/ManualTimeProvider.cs new file mode 100644 index 0000000..c0258f0 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/ManualTimeProvider.cs @@ -0,0 +1,8 @@ +namespace Influunt.Scheduler.Tests; + +internal sealed class ManualTimeProvider : TimeProvider +{ + private long _ticks = new DateTimeOffset(2030, 1, 1, 12, 0, 0, TimeSpan.Zero).Ticks; + public override DateTimeOffset GetUtcNow() => new(Interlocked.Read(ref _ticks), TimeSpan.Zero); + public void Advance(TimeSpan duration) => Interlocked.Add(ref _ticks, duration.Ticks); +} diff --git a/tests/Influunt.Scheduler.Tests/SchedulerBoundaryTests.cs b/tests/Influunt.Scheduler.Tests/SchedulerBoundaryTests.cs new file mode 100644 index 0000000..34edf87 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/SchedulerBoundaryTests.cs @@ -0,0 +1,479 @@ +using System.Collections.Concurrent; +using FakeItEasy; +using Influunt.Scheduler.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using MongoDB.Driver; + +namespace Influunt.Scheduler.Tests; + +public sealed partial class SchedulerReliabilityTests +{ + [Theory] + [InlineData(ExecutionStatus.Pending, -1, true)] + [InlineData(ExecutionStatus.Pending, 0, true)] + [InlineData(ExecutionStatus.Pending, 1, false)] + [InlineData(ExecutionStatus.Running, -1, true)] + [InlineData(ExecutionStatus.Running, 0, true)] + [InlineData(ExecutionStatus.Running, 1, false)] + public async Task Lease_Is_Acquirable_Exactly_At_Expiry(ExecutionStatus status, int offsetMilliseconds, bool expectedStart) + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + var trigger = await h.ScheduleAsync(); + var expiry = clock.GetUtcNow().UtcDateTime.AddMilliseconds(offsetMilliseconds); + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, TriggerDocumentId = trigger.Id, ScheduledRunTime = trigger.NextRunTime, + JobType = trigger.JobType, Status = status, LockId = "previous", LockExpiry = expiry + }); + await h.Locks.InsertOneAsync(new JobTypeLock { Id = trigger.JobType, LockId = "previous", LockExpiry = expiry }); + + Assert.Equal(expectedStart, await h.Node.ScheduleJobAsync(trigger)); + if (expectedStart) + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(expectedStart ? 1 : 0, h.Job.Count); + } + + [Theory] + [InlineData(false, 0)] + [InlineData(false, 1)] + [InlineData(true, 0)] + [InlineData(true, 1)] + public async Task Heartbeat_Does_Not_Revive_An_Expired_Lease(bool jobTypeLease, int remainingMilliseconds) + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + h.Job.Block(); + var trigger = await h.ScheduleAsync(allowConcurrent: !jobTypeLease); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + var expiry = clock.GetUtcNow().UtcDateTime.AddMilliseconds(remainingMilliseconds); + if (jobTypeLease) + await h.Locks.UpdateOneAsync(x => true, Builders.Update.Set(x => x.LockExpiry, expiry)); + else + await h.Executions.UpdateOneAsync(x => true, Builders.Update.Set(x => x.LockExpiry, expiry)); + + await h.Node.HeartbeatAsync(); + if (remainingMilliseconds == 0) + { + await h.Job.Cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await h.Node.StopAsync(default); + Assert.Equal(ExecutionStatus.Running, (await h.Executions.Find(x => true).SingleAsync()).Status); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + } + else + { + Assert.False(h.Job.Cancelled.Task.IsCompleted); + Assert.Equal(clock.GetUtcNow().UtcDateTime.AddSeconds(30), (await h.Executions.Find(x => true).SingleAsync()).LockExpiry); + } + } + + [Fact] + public async Task Finishing_At_Lease_Expiry_Cannot_Consume_The_Occurrence() + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + h.Job.Block(); + var trigger = await h.ScheduleAsync(); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + clock.Advance(TimeSpan.FromSeconds(30)); + h.Job.Release.TrySetResult(); + await h.Node.StopAsync(default); + Assert.Equal(ExecutionStatus.Running, (await h.Executions.Find(x => true).SingleAsync()).Status); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + Assert.True(await h.NewNode().ScheduleJobAsync(trigger)); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(2, h.Job.Count); + } + + [Fact] + public async Task Future_Trigger_Fires_At_Its_Due_Time_And_Not_Before() + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + var trigger = await h.ScheduleAsync(); + var due = clock.GetUtcNow().UtcDateTime.AddMilliseconds(1); + await h.Triggers.UpdateOneAsync(x => x.Id == trigger.Id, Builders.Update.Set(x => x.NextRunTime, due)); + await h.Node.ScanTriggersAsync(); + Assert.Equal(0, h.Job.Count); + clock.Advance(TimeSpan.FromMilliseconds(1)); + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(1, h.Job.Count); + } + + [Fact] + public async Task Scan_Respects_MaxConcurrentJobs_And_Resumes_Queued_Work() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, maxJobs: 2, batchSize: 1); + var active = 0; + var peak = 0; + var monitor = new object(); + h.Job.Handler = async token => + { + lock (monitor) { active++; peak = Math.Max(peak, active); } + try { await h.Job.Release.Task.WaitAsync(token); } + finally { lock (monitor) active--; } + }; + for (var i = 0; i < 6; i++) + await h.ScheduleAsync("queued-" + i, allowConcurrent: true); + await h.Node.ScanTriggersAsync(); + await h.WaitUntilAsync(() => h.Job.Count == 2); + await h.Node.ScanTriggersAsync(); + Assert.Equal(2, h.Job.Count); + h.Job.Release.TrySetResult(); + await h.WaitUntilAsync(async () => + { + await h.Node.ScanTriggersAsync(); + return await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed) == 6; + }); + Assert.Equal(2, peak); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Completion_And_Trigger_Update_Are_Recoverable_After_A_Write_Failure(bool recurring) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(recurring: recurring); + var triggers = A.Fake>(o => o.Wrapping(h.Triggers)); + if (recurring) + A.CallTo(() => triggers.UpdateOneAsync(A>._, A>._, A._, A._)) + .ThrowsAsync(new MongoException("trigger write unavailable")); + else + A.CallTo(() => triggers.DeleteOneAsync(A>._, A._)) + .ThrowsAsync(new MongoException("trigger write unavailable")); + var failedNode = h.NewNode(ContextWith(h, Constants.JobTriggersCollectionName, triggers)); + Assert.True(await failedNode.ScheduleJobAsync(trigger)); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + await failedNode.StopAsync(default); + Assert.Equal(trigger.NextRunTime, (await h.Triggers.Find(x => true).SingleAsync()).NextRunTime); + + await h.Node.ScanTriggersAsync(); + Assert.Equal(1, h.Job.Count); + Assert.Single(await h.Executions.Find(x => true).ToListAsync()); + if (recurring) + Assert.True((await h.Triggers.Find(x => true).SingleAsync()).NextRunTime > DateTime.UtcNow); + else + Assert.False(await h.Triggers.Find(x => true).AnyAsync()); + } + + [Fact] + public async Task Failed_Completion_Write_Leaves_Work_Recoverable() + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + var trigger = await h.ScheduleAsync(); + var executions = A.Fake>(o => o.Wrapping(h.Executions)); + A.CallTo(() => executions.UpdateOneAsync(A>._, A>._, A._, A._)) + .ThrowsAsync(new MongoException("completion write unavailable")); + var failedNode = h.NewNode(ContextWith(h, Constants.JobExecutionsCollectionName, executions)); + Assert.True(await failedNode.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + await failedNode.StopAsync(default); + Assert.Equal(ExecutionStatus.Running, (await h.Executions.Find(x => true).SingleAsync()).Status); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + clock.Advance(TimeSpan.FromSeconds(30)); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(2, h.Job.Count); + } + + [Fact] + public async Task Scan_Recovers_After_A_Query_Exception() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + await h.ScheduleAsync(); + var calls = 0; + var triggers = A.Fake>(o => o.Wrapping(h.Triggers)); + A.CallTo(() => triggers.FindAsync(A>._, A>._, A._)) + .ReturnsLazily((FilterDefinition filter, FindOptions options, CancellationToken token) => + Interlocked.Increment(ref calls) == 1 + ? Task.FromException>(new MongoException("query unavailable")) + : h.Triggers.FindAsync(filter, options, token)); + var node = h.NewNode(ContextWith(h, Constants.JobTriggersCollectionName, triggers)); + await node.ScanTriggersAsync(); + Assert.Equal(0, h.Job.Count); + await node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Overlapping_Scan_Is_Skipped_And_Stop_Drains_An_InFlight_Scan(bool stop) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + await h.ScheduleAsync(); + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + var triggers = A.Fake>(o => o.Wrapping(h.Triggers)); + A.CallTo(() => triggers.FindAsync(A>._, A>._, A._)) + .ReturnsLazily(async (FilterDefinition filter, FindOptions options, CancellationToken token) => + { + Interlocked.Increment(ref calls); + reached.TrySetResult(); + await resume.Task; + return await h.Triggers.FindAsync(filter, options, token); + }); + var node = h.NewNode(ContextWith(h, Constants.JobTriggersCollectionName, triggers)); + var scan = node.ScanTriggersAsync(); + try + { + await reached.Task.WaitAsync(TimeSpan.FromSeconds(5)); + var next = stop ? node.StopAsync(default) : node.ScanTriggersAsync(); + if (stop) + Assert.False(next.IsCompleted); + else + await next.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(1, calls); + resume.TrySetResult(); + await Task.WhenAll(scan, next).WaitAsync(TimeSpan.FromSeconds(5)); + if (stop) + Assert.Equal(0, h.Job.Count); + else + await h.WaitForStatusAsync(ExecutionStatus.Completed); + } + finally { resume.TrySetResult(); } + } + + [Fact] + public async Task Missing_Job_Definition_Does_Not_Block_Later_Triggers() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, batchSize: 1); + await h.Triggers.InsertOneAsync(new JobTrigger { TriggerId = "orphan", JobType = "missing", NextRunTime = DateTime.UtcNow.AddDays(-1) }); + await h.ScheduleAsync(); + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(1, h.Job.Count); + Assert.True(await h.Triggers.Find(x => x.TriggerId == "orphan").AnyAsync()); + } + + [Fact] + public async Task Stop_Respects_Host_Deadline_When_A_Job_Ignores_Cancellation() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Handler = _ => h.Job.Release.Task; + var trigger = await h.ScheduleAsync(); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + try + { + await h.Node.StopAsync(new CancellationToken(true)).WaitAsync(TimeSpan.FromSeconds(5)); + Assert.False(await h.Node.ScheduleJobAsync(trigger)); + Assert.Equal(ExecutionStatus.Running, (await h.Executions.Find(x => true).SingleAsync()).Status); + } + finally { h.Job.Release.TrySetResult(); } + } + + [Fact] + public async Task Stop_During_Final_Lease_Renewal_Cannot_Register_A_New_Job() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + await h.ScheduleAsync(); + var reached = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var calls = 0; + var locks = A.Fake>(o => o.Wrapping(h.Locks)); + A.CallTo(() => locks.UpdateOneAsync(A>._, A>._, A._, A._)) + .ReturnsLazily(async (FilterDefinition filter, UpdateDefinition update, UpdateOptions options, CancellationToken token) => + { + var result = await h.Locks.UpdateOneAsync(filter, update, options, token); + if (Interlocked.Increment(ref calls) == 1) + { + reached.TrySetResult(); + await resume.Task; + } + return result; + }); + var node = h.NewNode(ContextWith(h, Constants.JobLocksCollectionName, locks)); + var scan = node.ScanTriggersAsync(); + try + { + await reached.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await node.StopAsync(new CancellationToken(true)).WaitAsync(TimeSpan.FromSeconds(5)); + resume.TrySetResult(); + await scan.WaitAsync(TimeSpan.FromSeconds(5)); + Assert.Equal(0, h.Job.Count); + var execution = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(ExecutionStatus.Running, execution.Status); + Assert.True(execution.LockExpiry <= DateTime.UtcNow); + Assert.False(await h.Locks.Find(x => x.LockId != null).AnyAsync()); + } + finally { resume.TrySetResult(); } + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Removed_Trigger_Or_Expired_Lease_During_Acquisition_Does_Not_Start_Work(bool removeTrigger) + { + var clock = new ManualTimeProvider(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, clock: clock); + var trigger = await h.ScheduleAsync(); + var executions = A.Fake>(o => o.Wrapping(h.Executions)); + A.CallTo(() => executions.FindOneAndUpdateAsync(A>._, A>._, + A>._, A._)) + .ReturnsLazily(async (FilterDefinition filter, UpdateDefinition update, + FindOneAndUpdateOptions options, CancellationToken token) => + { + var result = await h.Executions.FindOneAndUpdateAsync(filter, update, options, token); + if (!options.IsUpsert) + { + if (removeTrigger) + await h.Triggers.DeleteOneAsync(x => x.Id == trigger.Id); + else + clock.Advance(TimeSpan.FromSeconds(30)); + } + return result; + }); + var node = h.NewNode(ContextWith(h, Constants.JobExecutionsCollectionName, executions)); + Assert.False(await node.ScheduleJobAsync(trigger)); + Assert.Equal(0, h.Job.Count); + Assert.True((await h.Executions.Find(x => true).SingleAsync()).LockExpiry <= clock.GetUtcNow().UtcDateTime); + Assert.False(await h.Locks.Find(x => x.LockId != null).AnyAsync()); + } + + [Theory] + [InlineData("job-type")] + [InlineData("settings-type")] + public async Task Unloadable_Types_Fail_Once_Without_Poisoning_The_Queue(string invalid) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(); + var definitions = h.Context.GetCollection(Constants.JobDefinitionsCollectionName); + if (invalid == "job-type") + { + trigger.JobType = "Missing.Job, Missing.Assembly"; + await definitions.UpdateOneAsync(x => true, Builders.Update.Set(x => x.JobType, trigger.JobType)); + await h.Triggers.UpdateOneAsync(x => true, Builders.Update.Set(x => x.JobType, trigger.JobType)); + } + else + await definitions.UpdateOneAsync(x => true, Builders.Update.Set(x => x.SettingsType, "Missing.Settings, Missing.Assembly")); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitForStatusAsync(ExecutionStatus.Failed); + await h.WaitUntilAsync(async () => !await h.Triggers.Find(x => true).AnyAsync()); + Assert.Equal(0, h.Job.Count); + await h.Node.ScanTriggersAsync(); + Assert.Single(await h.Executions.Find(x => true).ToListAsync()); + } + + [Theory] + [InlineData(false, false)] + [InlineData(true, false)] + [InlineData(false, true)] + public async Task Generic_Job_Handles_Explicit_Interface_Default_Settings_And_Invalid_Json(bool emptySettings, bool invalidJson) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, configure: services => services.AddSingleton()); + await h.Node.RegisterJobAsync("settings"); + var id = await h.Node.ScheduleRecurringJobAsync("0 0 1 1 *", new() { Text = "payload" }); + var update = Builders.Update.Set(x => x.NextRunTime, DateTime.UtcNow.AddSeconds(-1)); + if (emptySettings || invalidJson) + update = update.Set(x => x.SettingsJson, invalidJson ? "{ broken json" : null); + await h.Triggers.UpdateOneAsync(x => x.TriggerId == id, update); + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(invalidJson ? ExecutionStatus.Failed : ExecutionStatus.Completed); + var job = h.Services.GetRequiredService(); + Assert.Equal(invalidJson ? 0 : 1, job.Calls); + if (!invalidJson) + Assert.Equal(emptySettings ? null : "payload", job.Last!.Text); + } + + [Fact] + public async Task Job_Scopes_Are_Separate_And_Disposed_On_Success_And_Failure() + { + var observer = new ScopeObserver(); + await using var h = await Harness.CreateAsync(mongo.ConnectionString, configure: services => + services.AddSingleton(observer).AddScoped().AddTransient()); + await h.Node.RegisterJobAsync("scoped", allowConcurrentExecution: true); + await h.Node.ScheduleOneTimeJobAsync(); + await h.Node.ScheduleOneTimeJobAsync(); + await h.Node.ScanTriggersAsync(); + await h.WaitUntilAsync(async () => await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed || x.Status == ExecutionStatus.Failed) == 2); + Assert.Equal(2, observer.Used.Distinct().Count()); + Assert.Equal(observer.Used.OrderBy(x => x), observer.Disposed.OrderBy(x => x)); + Assert.Equal(1, await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Failed)); + } + + [Fact] + public async Task ReRegistering_A_Job_Updates_Options_Without_Changing_Its_Identity() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + await h.Node.RegisterJobAsync("controlled", "first"); + var before = Assert.Single(await h.Node.GetRegisteredJobsAsync()); + await h.NewNode().RegisterJobAsync("controlled", "changed", allowConcurrentExecution: true); + var after = Assert.Single(await h.Node.GetRegisteredJobsAsync()); + Assert.Equal(before.Id, after.Id); + Assert.Equal("changed", after.Description); + Assert.True(after.AllowConcurrentExecution); + } + + [Fact] + public async Task Synchronous_Cancellation_In_A_Generic_Job_Remains_Recoverable() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, configure: s => s.AddSingleton()); + await h.Node.RegisterJobAsync("sync-cancel"); + await h.Node.ScheduleOneTimeJobAsync(new()); + await h.Node.ScanTriggersAsync(); + await h.Services.GetRequiredService().Started.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await h.Node.StopAsync(default); + var interrupted = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(ExecutionStatus.Running, interrupted.Status); + Assert.True(interrupted.LockExpiry <= DateTime.UtcNow); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + } + + private static IMongoContext ContextWith(Harness h, string name, IMongoCollection collection) + { + var context = A.Fake(o => o.Wrapping(h.Context)); + A.CallTo(() => context.GetCollection(name)).Returns(collection); + return context; + } + + public sealed class ExplicitSettingsJob : IJob + { + public int Calls; + public JobSchedulerUnitTests.Payload? Last; + Task IJob.ExecuteAsync(JobSchedulerUnitTests.Payload settings, CancellationToken token) + { Last = settings; Interlocked.Increment(ref Calls); return Task.CompletedTask; } + } + + public sealed class SynchronousCancellationJob : IJob + { + public TaskCompletionSource Started { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public Task ExecuteAsync(JobSchedulerUnitTests.Payload settings, CancellationToken token) + { + Started.TrySetResult(); + if (!token.WaitHandle.WaitOne(TimeSpan.FromSeconds(5))) + throw new TimeoutException("Cancellation was not requested."); + token.ThrowIfCancellationRequested(); + return Task.CompletedTask; + } + } + + public sealed class ScopeObserver + { + public ConcurrentBag Used { get; } = new(); + public ConcurrentBag Disposed { get; } = new(); + public int Calls; + } + public sealed class ScopedDependency(ScopeObserver observer) : IDisposable + { + public Guid Id { get; } = Guid.NewGuid(); + public ScopeObserver Observer => observer; + public void Dispose() => observer.Disposed.Add(Id); + } + public sealed class ScopedJob(ScopedDependency dependency) : IJob + { + public Task ExecuteAsync(CancellationToken token) + { + dependency.Observer.Used.Add(dependency.Id); + if (Interlocked.Increment(ref dependency.Observer.Calls) == 1) + throw new InvalidOperationException("scoped job failure"); + return Task.CompletedTask; + } + } +} diff --git a/tests/Influunt.Scheduler.Tests/SchedulerIntegrationTestBase.cs b/tests/Influunt.Scheduler.Tests/SchedulerIntegrationTestBase.cs new file mode 100644 index 0000000..1806378 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/SchedulerIntegrationTestBase.cs @@ -0,0 +1,77 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Testcontainers.MongoDb; + +namespace Influunt.Scheduler.Tests; + +public abstract class SchedulerIntegrationTestBase : IAsyncLifetime +{ + private readonly MongoDbContainer _mongoContainer; + private IHost _host; + private IServiceProvider _serviceProvider; + + protected SchedulerIntegrationTestBase() + { + _mongoContainer = new MongoDbBuilder() + .WithImage("mongo:latest") + .Build(); + } + + protected IServiceProvider ServiceProvider => _serviceProvider ?? throw new InvalidOperationException("Test not initialized"); + + protected T GetRequiredService() where T : notnull + { + return ServiceProvider.GetRequiredService(); + } + + public async Task InitializeAsync() + { + await _mongoContainer.StartAsync(); + + var connectionStringUriBuilder = new UriBuilder(_mongoContainer.GetConnectionString()); + connectionStringUriBuilder.Path = "test-db"; + connectionStringUriBuilder.Query += "&authMechanism=SCRAM-SHA-256&authSource=admin"; + + _host = Host.CreateDefaultBuilder() + .ConfigureServices(services => + { + ConfigureServices(services, connectionStringUriBuilder.ToString()); + }) + .ConfigureLogging(logging => + { + logging.ClearProviders(); + logging.AddConsole(); + }) + .Build(); + + _serviceProvider = _host.Services; + + await InitializeTestDataAsync(); + + await _host.StartAsync(CancellationToken.None); + + await Task.Delay(TimeSpan.FromSeconds(5)); // Time for index creation and scheduler startup + } + + protected virtual void ConfigureServices(IServiceCollection services, string connectionString) + { + // Override in derived tests to add additional services + } + + protected virtual Task InitializeTestDataAsync() + { + return Task.CompletedTask; + } + + public async Task DisposeAsync() + { + if (_host != null) + { + await _host.StopAsync(); + _host.Dispose(); + } + + await _mongoContainer.DisposeAsync(); + } +} \ No newline at end of file diff --git a/tests/Influunt.Scheduler.Tests/SchedulerReliabilityTests.cs b/tests/Influunt.Scheduler.Tests/SchedulerReliabilityTests.cs new file mode 100644 index 0000000..c15dd64 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/SchedulerReliabilityTests.cs @@ -0,0 +1,505 @@ +using Influunt.Scheduler.Abstractions.Models; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Options; +using MongoDB.Bson; +using MongoDB.Driver; +using Testcontainers.MongoDb; + +namespace Influunt.Scheduler.Tests; + +public sealed class SchedulerMongoFixture : IAsyncLifetime +{ + private readonly MongoDbContainer _container = new MongoDbBuilder().WithImage("mongo:latest").Build(); + public string ConnectionString => _container.GetConnectionString(); + public Task InitializeAsync() => _container.StartAsync(); + public Task DisposeAsync() => _container.DisposeAsync().AsTask(); +} + +[Trait("Category", "Integration")] +public sealed partial class SchedulerReliabilityTests(SchedulerMongoFixture mongo) : IClassFixture +{ + [Fact] + public async Task Acquired_Execution_Is_Running_And_Heartbeat_Renews_It() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Block(); + var trigger = await h.ScheduleAsync(); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + var execution = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(ExecutionStatus.Running, execution.Status); + + var shortenedExpiry = DateTime.UtcNow.AddSeconds(2); + await h.Executions.UpdateOneAsync(x => x.Id == execution.Id, + Builders.Update.Set(x => x.LockExpiry, shortenedExpiry)); + await h.Node.HeartbeatAsync(); + execution = await h.Executions.Find(x => true).SingleAsync(); + Assert.True(execution.LockExpiry > shortenedExpiry); + Assert.False(await h.NewNode().ScheduleJobAsync(trigger)); + } + + [Theory] + [InlineData(ExecutionStatus.Pending, false)] + [InlineData(ExecutionStatus.Running, false)] + [InlineData(ExecutionStatus.Pending, true)] + [InlineData(ExecutionStatus.Running, true)] + public async Task Expired_Legacy_Execution_Is_Recovered(ExecutionStatus status, bool recurring) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(recurring: recurring, legacyTrigger: true); + var abandoned = new JobExecution + { + TriggerId = trigger.TriggerId, JobType = trigger.JobType, Status = status, + StartTime = trigger.NextRunTime.AddSeconds(5), LockId = "dead-node", + LockExpiry = DateTime.UtcNow.AddMinutes(-1) + }; + await h.Executions.InsertOneAsync(abandoned); + await h.Locks.InsertOneAsync(new JobTypeLock + { + Id = trigger.JobType, LockId = "dead-node", LockExpiry = DateTime.UtcNow.AddMinutes(-1) + }); + + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + var recovered = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(1, h.Job.Count); + Assert.Equal(abandoned.Id, recovered.Id); + Assert.Equal(trigger.Id, recovered.TriggerDocumentId); + Assert.Equal(trigger.NextRunTime, recovered.ScheduledRunTime); + Assert.NotEqual("dead-node", recovered.LockId); + } + + [Theory] + [InlineData(ExecutionStatus.Pending)] + [InlineData(ExecutionStatus.Running)] + public async Task Active_Legacy_Lease_Is_Not_Stolen(ExecutionStatus status) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(allowConcurrent: true, legacyTrigger: true); + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, JobType = trigger.JobType, Status = status, + LockId = "active-node", LockExpiry = DateTime.UtcNow.AddMinutes(1) + }); + + Assert.False(await h.Node.ScheduleJobAsync(trigger)); + Assert.Equal(0, h.Job.Count); + Assert.Equal("active-node", (await h.Executions.Find(x => true).SingleAsync()).LockId); + } + + [Fact] + public async Task Legacy_Pending_Document_Without_Default_Status_Is_Recovered() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(legacyTrigger: true); + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, JobType = trigger.JobType, + LockId = "dead-node", LockExpiry = DateTime.UtcNow.AddMinutes(-1) + }); + await h.Executions.UpdateOneAsync(x => true, Builders.Update.Unset(x => x.Status)); + + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(1, h.Job.Count); + } + + [Theory] + [InlineData(ExecutionStatus.Completed)] + [InlineData(ExecutionStatus.Failed)] + public async Task Legacy_Terminal_Document_Consumes_An_Unadvanced_Occurrence(ExecutionStatus status) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(recurring: true, legacyTrigger: true); + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, JobType = trigger.JobType, Status = status, + StartTime = trigger.NextRunTime, EndTime = DateTime.UtcNow.AddSeconds(-10) + }); + + await h.Node.ScanTriggersAsync(); + Assert.Equal(0, h.Job.Count); + Assert.True((await h.Triggers.Find(x => true).SingleAsync()).NextRunTime > DateTime.UtcNow); + Assert.Equal(trigger.Id, (await h.Executions.Find(x => true).SingleAsync()).TriggerDocumentId); + } + + [Fact] + public async Task New_Trigger_In_The_Past_Does_Not_Inherit_A_Legacy_Completion() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(); + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, JobType = trigger.JobType, + Status = ExecutionStatus.Completed, EndTime = DateTime.UtcNow + }); + + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + await h.WaitUntilAsync(async () => await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed) == 2); + } + + [Fact] + public async Task Interrupted_Execution_Remains_Recoverable_After_Shutdown() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Block(); + var trigger = await h.ScheduleAsync(); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + await h.Node.StopAsync(default); + + var interrupted = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(ExecutionStatus.Running, interrupted.Status); + Assert.True(interrupted.LockExpiry <= DateTime.UtcNow); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + h.Job.Release.TrySetResult(); + Assert.True(await h.NewNode().ScheduleJobAsync(trigger)); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + Assert.Equal(2, h.Job.Count); + } + + [Theory] + [InlineData(false, 1)] + [InlineData(true, 2)] + public async Task Job_Type_Concurrency_Is_Enforced_Across_Nodes(bool allowConcurrent, int expectedStarts) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Block(); + var first = await h.ScheduleAsync("first", allowConcurrent: allowConcurrent); + var second = await h.ScheduleAsync("second", allowConcurrent: allowConcurrent); + var other = h.NewNode(); + + var started = await Task.WhenAll(h.Node.ScheduleJobAsync(first), other.ScheduleJobAsync(second)); + Assert.Equal(expectedStarts, started.Count(x => x)); + await h.WaitUntilAsync(() => h.Job.Count == expectedStarts); + Assert.Equal(expectedStarts, await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Running)); + + h.Job.Release.TrySetResult(); + await h.WaitUntilAsync(async () => await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed) == expectedStarts); + // Wait for the owner to release its job-type lease before looking for remaining work. + await h.WaitUntilAsync(async () => !await h.Locks.Find(x => x.LockId != null).AnyAsync()); + await other.ScanTriggersAsync(); + await h.WaitUntilAsync(() => h.Job.Count == 2); + } + + [Fact] + public async Task Same_Occurrence_Is_Acquired_Only_Once_Across_Nodes() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Block(); + var trigger = await h.ScheduleAsync(allowConcurrent: true); + var nodes = Enumerable.Range(0, 8).Select(_ => h.NewNode()).ToArray(); + var results = await Task.WhenAll(nodes.Select(n => n.ScheduleJobAsync(trigger))); + Assert.Single(results, x => x); + await h.WaitUntilAsync(() => h.Job.Count == 1); + Assert.Equal(1, await h.Executions.CountDocumentsAsync(x => true)); + } + + [Fact] + public async Task Recurring_Occurrences_Keep_Separate_Completion_Records() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var first = await h.ScheduleAsync(recurring: true); + Assert.True(await h.Node.ScheduleJobAsync(first)); + await h.WaitForStatusAsync(ExecutionStatus.Completed); + await h.WaitUntilAsync(async () => !await h.Locks.Find(x => x.LockId != null).AnyAsync()); + + await h.Triggers.UpdateOneAsync(x => x.Id == first.Id, + Builders.Update.Set(x => x.NextRunTime, first.NextRunTime.AddSeconds(10))); + var second = await h.Triggers.Find(x => x.Id == first.Id).SingleAsync(); + Assert.True(await h.Node.ScheduleJobAsync(second)); + await h.WaitUntilAsync(async () => await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed) == 2); + Assert.False(await h.NewNode().ScheduleJobAsync(first)); + Assert.Equal(2, h.Job.Count); + } + + [Fact] + public async Task Stale_OneTime_Trigger_Cannot_Run_Again_Or_Affect_A_New_Generation() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var oldTrigger = await h.ScheduleAsync("reused", allowConcurrent: true); + Assert.True(await h.Node.ScheduleJobAsync(oldTrigger)); + await h.WaitUntilAsync(async () => !await h.Triggers.Find(x => true).AnyAsync()); + Assert.False(await h.NewNode().ScheduleJobAsync(oldTrigger)); + + var replacement = await h.ScheduleAsync("reused", allowConcurrent: true); + Assert.NotEqual(oldTrigger.Id, replacement.Id); + Assert.False(await h.Node.ScheduleJobAsync(oldTrigger)); + Assert.True(await h.Node.ScheduleJobAsync(replacement)); + await h.WaitUntilAsync(async () => await h.Executions.CountDocumentsAsync(x => x.Status == ExecutionStatus.Completed) == 2); + Assert.Equal(2, h.Job.Count); + } + + [Theory] + [InlineData(false, ExecutionStatus.Completed)] + [InlineData(true, ExecutionStatus.Completed)] + [InlineData(false, ExecutionStatus.Failed)] + [InlineData(true, ExecutionStatus.Failed)] + public async Task Terminal_Receipt_Repairs_Unadvanced_Trigger_Without_Reexecution(bool recurring, ExecutionStatus status) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var trigger = await h.ScheduleAsync(recurring: recurring); + var nextRun = recurring ? DateTime.UtcNow.AddDays(2) : (DateTime?)null; + await h.Executions.InsertOneAsync(new JobExecution + { + TriggerId = trigger.TriggerId, TriggerDocumentId = trigger.Id, ScheduledRunTime = trigger.NextRunTime, + JobType = trigger.JobType, Status = status, EndTime = DateTime.UtcNow, NextRunTime = nextRun, + LockId = "finished-attempt" + }); + + await h.Node.ScanTriggersAsync(); + Assert.Equal(0, h.Job.Count); + if (recurring) + { + var repaired = await h.Triggers.Find(x => true).SingleAsync(); + Assert.Equal(nextRun!.Value.Ticks / TimeSpan.TicksPerMillisecond, repaired.NextRunTime.Ticks / TimeSpan.TicksPerMillisecond); + } + else + Assert.False(await h.Triggers.Find(x => true).AnyAsync()); + Assert.Equal(status, (await h.Executions.Find(x => true).SingleAsync()).Status); + } + + [Fact] + public async Task Old_Owner_Cannot_Complete_Replacement_Attempt() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + var firstDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var replacementDone = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + h.Job.Handler = _ => h.Job.Count == 1 ? firstDone.Task : replacementDone.Task; + var trigger = await h.ScheduleAsync(allowConcurrent: true); + try + { + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + var first = await h.Executions.Find(x => true).SingleAsync(); + await h.Executions.UpdateOneAsync(x => x.Id == first.Id, + Builders.Update.Set(x => x.LockExpiry, DateTime.UtcNow.AddMinutes(-1))); + + var replacementNode = h.NewNode(); + Assert.True(await replacementNode.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 2); + var replacement = await h.Executions.Find(x => true).SingleAsync(); + Assert.NotEqual(first.LockId, replacement.LockId); + + firstDone.TrySetResult(); + await h.Node.StopAsync(default); + var stillRunning = await h.Executions.Find(x => true).SingleAsync(); + Assert.Equal(ExecutionStatus.Running, stillRunning.Status); + Assert.Equal(replacement.LockId, stillRunning.LockId); + Assert.True(await h.Triggers.Find(x => x.Id == trigger.Id).AnyAsync()); + await replacementNode.HeartbeatAsync(); + } + finally + { + firstDone.TrySetResult(); + replacementDone.TrySetResult(); + } + await h.WaitForStatusAsync(ExecutionStatus.Completed); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Heartbeat_Cancels_Job_When_Ownership_Is_Lost(bool loseJobTypeLock) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Block(); + var trigger = await h.ScheduleAsync(allowConcurrent: !loseJobTypeLock); + Assert.True(await h.Node.ScheduleJobAsync(trigger)); + await h.WaitUntilAsync(() => h.Job.Count == 1); + if (loseJobTypeLock) + await h.Locks.UpdateOneAsync(x => x.Id == trigger.JobType, Builders.Update.Set(x => x.LockId, "replacement")); + else + await h.Executions.UpdateOneAsync(x => true, Builders.Update.Set(x => x.LockId, "replacement")); + + await h.Node.HeartbeatAsync(); + await h.Job.Cancelled.Task.WaitAsync(TimeSpan.FromSeconds(5)); + await h.Node.StopAsync(default); + Assert.Equal(ExecutionStatus.Running, (await h.Executions.Find(x => true).SingleAsync()).Status); + Assert.True(await h.Triggers.Find(x => true).AnyAsync()); + if (loseJobTypeLock) + Assert.Equal("replacement", (await h.Locks.Find(x => true).SingleAsync()).LockId); + else + Assert.Equal("replacement", (await h.Executions.Find(x => true).SingleAsync()).LockId); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Scan_Finds_Work_Beyond_Busy_Triggers_And_Batches(bool allowConcurrent) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, maxJobs: 3, batchSize: 1); + h.Job.Block(); + var blockedCount = allowConcurrent ? 2 : 1; + for (var i = 0; i < blockedCount; i++) + await h.ScheduleAsync("blocked-" + i, allowConcurrent: allowConcurrent); + await h.Node.ScanTriggersAsync(); + await h.WaitUntilAsync(() => h.Job.Count == blockedCount); + + if (!allowConcurrent) + for (var i = 0; i < 8; i++) + await h.ScheduleAsync("waiting-" + i); + await h.Node.RegisterJobAsync("quick"); + await h.Node.ScheduleOneTimeJobAsync(); + await h.Node.ScanTriggersAsync(); + await h.WaitUntilAsync(() => h.Quick.Count == 1); + Assert.Equal(blockedCount, h.Job.Count); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task Failed_Occurrence_Is_Consumed_Without_Unbounded_Retry(bool recurring) + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString); + h.Job.Handler = _ => throw new InvalidOperationException("job failure"); + await h.ScheduleAsync(recurring: recurring); + await h.Node.ScanTriggersAsync(); + await h.WaitForStatusAsync(ExecutionStatus.Failed); + await h.WaitUntilAsync(async () => !await h.Locks.Find(x => x.LockId != null).AnyAsync()); + + for (var i = 0; i < 3; i++) + await h.Node.ScanTriggersAsync(); + Assert.Equal(1, h.Job.Count); + Assert.Single(await h.Executions.Find(x => true).ToListAsync()); + Assert.Contains("job failure", (await h.Executions.Find(x => true).SingleAsync()).Result); + if (recurring) + Assert.True((await h.Triggers.Find(x => true).SingleAsync()).NextRunTime > DateTime.UtcNow); + else + Assert.False(await h.Triggers.Find(x => true).AnyAsync()); + } + + [Fact] + public async Task Index_Migration_Preserves_Legacy_Executions_And_Allows_Recurring_History() + { + await using var h = await Harness.CreateAsync(mongo.ConnectionString, migrateLegacyIndex: true); + using var cursor = await h.Executions.Indexes.ListAsync(); + var indexes = await cursor.ToListAsync(); + Assert.Contains(indexes, x => x["name"] == "TriggerOccurrence" && x["unique"].AsBoolean); + Assert.DoesNotContain(indexes, x => x["key"].AsBsonDocument.ElementCount == 1 && + x["key"].AsBsonDocument.Contains("TriggerId") && x.GetValue("unique", false).AsBoolean); + Assert.True(await h.Executions.Find(x => x.TriggerId == "legacy-history").AnyAsync()); + } + + private sealed class Harness : IAsyncDisposable + { + private readonly ServiceProvider _provider; + public IServiceProvider Services => _provider; + public IMongoContext Context => _provider.GetRequiredService(); + public TimeProvider Clock => _provider.GetRequiredService(); + private readonly List _nodes = new(); + public JobScheduler Node => _nodes[0]; + public ControlledJob Job { get; } = new(); + public QuickJob Quick { get; } = new(); + public IMongoCollection Executions { get; } + public IMongoCollection Triggers { get; } + public IMongoCollection Locks { get; } + + private Harness(string connectionString, int maxJobs, int batchSize, TimeProvider? clock, Action? configure) + { + var uri = new UriBuilder(connectionString) { Path = "reliability_" + Guid.NewGuid().ToString("N") }; + uri.Query += "&authMechanism=SCRAM-SHA-256&authSource=admin"; + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(Job); + services.AddSingleton(Quick); + services.AddSingleton(clock ?? TimeProvider.System); + configure?.Invoke(services); + services.AddJobScheduler(o => + { + o.ConnectionString = uri.ToString(); + o.MaxConcurrentJobs = maxJobs; + o.ScanBatchSize = batchSize; + o.HeartbeatIntervalSeconds = 1; + o.LockTimeoutSeconds = 30; + }); + _provider = services.BuildServiceProvider(); + var context = _provider.GetRequiredService(); + Executions = context.GetCollection(Constants.JobExecutionsCollectionName); + Triggers = context.GetCollection(Constants.JobTriggersCollectionName); + Locks = context.GetCollection(Constants.JobLocksCollectionName); + NewNode(); + } + + public static async Task CreateAsync(string connectionString, int maxJobs = 10, int batchSize = 50, bool migrateLegacyIndex = false, + TimeProvider? clock = null, Action? configure = null) + { + var h = new Harness(connectionString, maxJobs, batchSize, clock, configure); + if (migrateLegacyIndex) + { + await h.Executions.Indexes.CreateOneAsync(new CreateIndexModel( + Builders.IndexKeys.Ascending(x => x.TriggerId), new CreateIndexOptions { Unique = true })); + await h.Executions.InsertOneAsync(new JobExecution { TriggerId = "legacy-history", Status = ExecutionStatus.Completed }); + } + await h._provider.GetServices().OfType().Single().StartAsync(default); + return h; + } + + public JobScheduler NewNode(IMongoContext? context = null) + { + var node = new JobScheduler(_provider, _provider.GetRequiredService>(), + NullLogger.Instance, context ?? Context, Clock); + _nodes.Add(node); + return node; + } + + public async Task ScheduleAsync(string id = "trigger", bool recurring = false, bool allowConcurrent = false, bool legacyTrigger = false) + { + await Node.RegisterJobAsync("controlled", allowConcurrentExecution: allowConcurrent); + if (recurring) + await Node.ScheduleRecurringJobAsync("0 0 1 1 *", id); + else + await Node.ScheduleOneTimeJobAsync(triggerId: id); + var update = Builders.Update.Set(x => x.NextRunTime, Clock.GetUtcNow().UtcDateTime.AddMinutes(-1)); + if (legacyTrigger) + update = update.Unset(x => x.CreatedAt); + await Triggers.UpdateOneAsync(x => x.TriggerId == id, update); + return await Triggers.Find(x => x.TriggerId == id).SingleAsync(); + } + + public Task WaitForStatusAsync(ExecutionStatus status) => WaitUntilAsync(async () => await Executions.Find(x => x.Status == status).AnyAsync()); + public Task WaitUntilAsync(Func condition) => WaitUntilAsync(() => Task.FromResult(condition())); + public async Task WaitUntilAsync(Func> condition) + { + using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + while (!await condition()) + await Task.Delay(10, timeout.Token); + } + + public async ValueTask DisposeAsync() + { + Job.Release.TrySetResult(); + foreach (var node in _nodes) + { + await node.StopAsync(default); + node.Dispose(); + } + await _provider.DisposeAsync(); + } + } + + public sealed class ControlledJob : IJob + { + private int _count; + public int Count => Volatile.Read(ref _count); + public Func Handler { get; set; } = _ => Task.CompletedTask; + public TaskCompletionSource Release { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public TaskCompletionSource Cancelled { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + public void Block() => Handler = token => Release.Task.WaitAsync(token); + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref _count); + try { await Handler(cancellationToken); } + catch (OperationCanceledException) { Cancelled.TrySetResult(); throw; } + } + } + + public sealed class QuickJob : IJob + { + public int Count; + public Task ExecuteAsync(CancellationToken cancellationToken) { Interlocked.Increment(ref Count); return Task.CompletedTask; } + } +} diff --git a/tests/Influunt.Scheduler.Tests/TestJobs.cs b/tests/Influunt.Scheduler.Tests/TestJobs.cs new file mode 100644 index 0000000..381cf79 --- /dev/null +++ b/tests/Influunt.Scheduler.Tests/TestJobs.cs @@ -0,0 +1,54 @@ +using Influunt.Scheduler.Abstractions.Models; + +namespace Influunt.Scheduler.Tests; + +public class TestJobWithoutSettings : IJob +{ + public static int ExecutionCount = 0; + public static bool WasExecuted { get; set; } + + public Task ExecuteAsync(CancellationToken cancellationToken) + { + WasExecuted = true; + Interlocked.Increment(ref ExecutionCount); + return Task.CompletedTask; + } +} + +public class TestJobWithSettings : IJob +{ + public static int ExecutionCount = 0; + public static TestJobSettings? LastSettings { get; set; } + public static bool WasExecuted { get; set; } + + public Task ExecuteAsync(TestJobSettings settings, CancellationToken cancellationToken) + { + LastSettings = settings; + WasExecuted = true; + Interlocked.Increment(ref ExecutionCount); + return Task.CompletedTask; + } +} + +public class TestJobSettings +{ + public string Message { get; set; } = string.Empty; + public int Count { get; set; } +} + +public class TestBlockingJob : IJob +{ + public static int ExecutionCount = 0; + public static TaskCompletionSource? ContinueExecution; + + public async Task ExecuteAsync(CancellationToken cancellationToken) + { + Interlocked.Increment(ref ExecutionCount); + var tcs = ContinueExecution; + if (tcs != null) + { + using var registration = cancellationToken.Register(() => tcs.TrySetCanceled()); + await tcs.Task; + } + } +} \ No newline at end of file