├── .dockerignore ├── .github └── workflows │ ├── SonarCloud.yml │ ├── docs.yml │ ├── publish-AsyncMonolith.Ef-nuget-package.yaml │ ├── publish-AsyncMonolith.MsSql-nuget-package.yaml │ ├── publish-AsyncMonolith.MySql-nuget-package.yaml │ ├── publish-AsyncMonolith.PostgreSql-nuget-package.yaml │ ├── publish-AsyncMonolith.TestHelpers-nuget-package.yaml │ └── test.yml ├── .gitignore ├── .idea └── .idea.AsyncMonolith │ └── .idea │ ├── .gitignore │ ├── encodings.xml │ ├── indexLayout.xml │ └── vcs.xml ├── AsyncMonolith.Ef ├── AsyncMonolith.Ef.csproj ├── EfConsumerMessageFetcher.cs ├── EfProducerService.cs ├── EfScheduledMessageFetcher.cs └── StartupExtensions.cs ├── AsyncMonolith.MariaDb ├── AsyncMonolith.MariaDb.csproj ├── MariaDbConsumerMessageFetcher.cs ├── MariaDbProducerService.cs ├── MariaDbScheduledMessageFetcher.cs └── StartupExtensions.cs ├── AsyncMonolith.MsSql ├── AsyncMonolith.MsSql.csproj ├── MsSqlConsumerMessageFetcher.cs ├── MsSqlProducerService.cs ├── MsSqlScheduledMessageFetcher.cs └── StartupExtensions.cs ├── AsyncMonolith.MySql ├── AsyncMonolith.MySql.csproj ├── MySqlConsumerMessageFetcher.cs ├── MySqlProducerService.cs ├── MySqlScheduledMessageFetcher.cs └── StartupExtensions.cs ├── AsyncMonolith.PostgreSql ├── AsyncMonolith.PostgreSql.csproj ├── PostgreSqlConsumerMessageFetcher.cs ├── PostgreSqlProducerService.cs ├── PostgreSqlScheduledMessageFetcher.cs └── StartupExtensions.cs ├── AsyncMonolith.TestHelpers ├── AsyncMonolith.TestHelpers.csproj ├── ConsumerMessageTestHelpers.cs ├── ConsumerTestBase.cs ├── FakeIdGenerator.cs ├── FakeProducerService.cs ├── FakeScheduleService.cs ├── SetupTestHelpers.cs └── TestConsumerMessageProcessor.cs ├── AsyncMonolith.Tests ├── AsyncMonolith.Tests.csproj ├── ConsumerMessageFetcherTests.cs ├── ConsumerMessageProcessorTests.cs ├── ConsumerRegistryTests.cs ├── Infra │ ├── DbTestsBase.cs │ ├── DbType.cs │ ├── EfTestDbContainer.cs │ ├── ExceptionConsumer.cs │ ├── ExceptionConsumer2Attempts.cs │ ├── ExceptionConsumer2AttemptsMessage.cs │ ├── ExceptionConsumerMessage.cs │ ├── MariaDbTestDbContainer.cs │ ├── MsSqlTestDbContainer.cs │ ├── MultiConsumer1.cs │ ├── MultiConsumer2.cs │ ├── MultiConsumerMessage.cs │ ├── MySqlTestDbContainer.cs │ ├── PostgreSqlTestDbContainer.cs │ ├── SingleConsumer.cs │ ├── SingleConsumerMessage.cs │ ├── TestConsumerInvocations.cs │ ├── TestDbContainerBase.cs │ ├── TestDbContext.cs │ ├── TestServiceHelpers.cs │ ├── TimeoutConsumer.cs │ └── TimeoutConsumerMessage.cs ├── ProducerServiceDbTests.cs ├── ProducerServiceTests.cs ├── ScheduledMessageFetcherTests.cs ├── ScheduledMessageProcessorTests.cs └── ScheduledMessageServiceTests.cs ├── AsyncMonolith.sln ├── AsyncMonolith.sln.DotSettings ├── AsyncMonolith ├── AsyncMonolith.csproj ├── Consumers │ ├── BaseConsumer.cs │ ├── ConsumerAttemptsAttribute.cs │ ├── ConsumerMessage.cs │ ├── ConsumerMessageProcessor.cs │ ├── ConsumerMessageProcessorFactory.cs │ ├── ConsumerRegistry.cs │ ├── ConsumerTimeoutAttribute.cs │ ├── IConsumer.cs │ ├── IConsumerMessageFetcher.cs │ ├── IConsumerPayload.cs │ └── PoisonedMessage.cs ├── Producers │ └── IProducerService.cs ├── Scheduling │ ├── IScheduleService.cs │ ├── IScheduledMessageFetcher.cs │ ├── ScheduleService.cs │ ├── ScheduledMessage.cs │ ├── ScheduledMessageProcessor.cs │ └── ScheduledMessageProcessorFactory.cs ├── Utilities │ ├── AsyncMonolithIdGenerator.cs │ ├── AsyncMonolithInstrumentation.cs │ ├── AsyncMonolithSettings.cs │ └── StartupExtensions.cs └── logo.png ├── Demo ├── ApplicationDbContext.cs ├── Counter │ ├── TotalValueConsumer.cs │ ├── TotalValueService.cs │ ├── ValueController.cs │ ├── ValuePersisted.cs │ ├── ValueSubmitted.cs │ └── ValueSubmittedConsumer.cs ├── Demo.csproj ├── Demo.http ├── Dockerfile ├── Migrations │ ├── 20240615074531_InitialMigration.Designer.cs │ ├── 20240615074531_InitialMigration.cs │ ├── 20240618182258_TraceId.Designer.cs │ ├── 20240618182258_TraceId.cs │ └── ApplicationDbContextModelSnapshot.cs ├── Program.cs ├── Properties │ └── launchSettings.json ├── Spam │ ├── SpamController.cs │ ├── SpamMessage.cs │ ├── SpamMessageConsumer.cs │ ├── SpamResultService.cs │ └── SubmittedValue.cs ├── appsettings.Development.json └── appsettings.json ├── Diagrams ├── AsyncMonolith.drawio └── AsyncMonolith.svg ├── LICENSE ├── README.md ├── Schemas ├── asyncmonolith_mariadb.sql ├── asyncmonolith_mssql.sql ├── asyncmonolith_mysql.sql └── asyncmonolith_postgresql.sql ├── default.DotSettings ├── docker-compose.dcproj ├── docker-compose.override.yml ├── docker-compose.yml ├── docs ├── assets │ ├── internals.svg │ └── logo.png ├── contributing.md ├── demo.md ├── guides │ ├── changing-messages.md │ ├── consuming-messages.md │ ├── opentelemetry.md │ ├── producing-messages.md │ └── scheduling-messages.md ├── index.md ├── internals.md ├── posts │ ├── idempotency.md │ ├── mediator.md │ └── transactional-outbox.md ├── quickstart.md ├── releases.md ├── support.md ├── tests.md └── warnings.md ├── launchSettings.json └── mkdocs.yml /.dockerignore: -------------------------------------------------------------------------------- 1 | **/.classpath 2 | **/.dockerignore 3 | **/.env 4 | **/.git 5 | **/.gitignore 6 | **/.project 7 | **/.settings 8 | **/.toolstarget 9 | **/.vs 10 | **/.vscode 11 | **/*.*proj.user 12 | **/*.dbmdl 13 | **/*.jfm 14 | **/azds.yaml 15 | **/bin 16 | **/charts 17 | **/docker-compose* 18 | **/Dockerfile* 19 | **/node_modules 20 | **/npm-debug.log 21 | **/obj 22 | **/secrets.dev.yaml 23 | **/values.dev.yaml 24 | LICENSE 25 | README.md 26 | !**/.gitignore 27 | !.git/HEAD 28 | !.git/config 29 | !.git/packed-refs 30 | !.git/refs/heads/** -------------------------------------------------------------------------------- /.github/workflows/SonarCloud.yml: -------------------------------------------------------------------------------- 1 | name: SonarCloud 2 | on: 3 | workflow_dispatch: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | types: [opened, synchronize, reopened] 9 | jobs: 10 | build: 11 | defaults: 12 | run: 13 | working-directory: AsyncMonolith 14 | name: Build and analyze 15 | runs-on: windows-latest 16 | steps: 17 | - name: Set up JDK 17 18 | uses: actions/setup-java@v3 19 | with: 20 | java-version: 17 21 | distribution: 'zulu' # Alternative distribution options are available. 22 | - uses: actions/checkout@v3 23 | with: 24 | fetch-depth: 0 # Shallow clones should be disabled for a better relevancy of analysis 25 | - name: Cache SonarCloud packages 26 | uses: actions/cache@v3 27 | with: 28 | path: ~\sonar\cache 29 | key: ${{ runner.os }}-sonar 30 | restore-keys: ${{ runner.os }}-sonar 31 | - name: Cache SonarCloud scanner 32 | id: cache-sonar-scanner 33 | uses: actions/cache@v3 34 | with: 35 | path: .\.sonar\scanner 36 | key: ${{ runner.os }}-sonar-scanner 37 | restore-keys: ${{ runner.os }}-sonar-scanner 38 | - name: Install SonarCloud scanner 39 | if: steps.cache-sonar-scanner.outputs.cache-hit != 'true' 40 | shell: powershell 41 | run: | 42 | New-Item -Path .\.sonar\scanner -ItemType Directory 43 | dotnet tool update dotnet-sonarscanner --tool-path .\.sonar\scanner 44 | - name: Build and analyze 45 | env: 46 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # Needed to get PR information, if any 47 | SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} 48 | shell: powershell 49 | run: | 50 | .\.sonar\scanner\dotnet-sonarscanner begin /k:"timmoth_asyncmonolith" /o:"timmoth" /d:sonar.token="${{ secrets.SONAR_TOKEN }}" /d:sonar.host.url="https://sonarcloud.io" 51 | dotnet build 52 | .\.sonar\scanner\dotnet-sonarscanner end /d:sonar.token="${{ secrets.SONAR_TOKEN }}" 53 | -------------------------------------------------------------------------------- /.github/workflows/docs.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | on: 3 | push: 4 | branches: 5 | - master 6 | - main 7 | permissions: 8 | contents: write 9 | jobs: 10 | deploy: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v4 14 | - name: Configure Git Credentials 15 | run: | 16 | git config user.name github-actions[bot] 17 | git config user.email 41898282+github-actions[bot]@users.noreply.github.com 18 | - uses: actions/setup-python@v5 19 | with: 20 | python-version: 3.x 21 | - run: echo "cache_id=$(date --utc '+%V')" >> $GITHUB_ENV 22 | - uses: actions/cache@v4 23 | with: 24 | key: mkdocs-material-${{ env.cache_id }} 25 | path: .cache 26 | restore-keys: | 27 | mkdocs-material- 28 | - run: pip install mkdocs-material 29 | - run: mkdocs gh-deploy --force 30 | -------------------------------------------------------------------------------- /.github/workflows/publish-AsyncMonolith.Ef-nuget-package.yaml: -------------------------------------------------------------------------------- 1 | name: Publish AsyncMonolith.Ef nuget package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | working-directory: AsyncMonolith.Ef 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | 16 | - name: Setup .NET Core 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 8.0.x 20 | 21 | - name: Build and Package 22 | run: | 23 | dotnet restore 24 | dotnet build -c Release 25 | dotnet pack --configuration Release --output nupkg 26 | 27 | - name: Publish to NuGet 28 | run: | 29 | dotnet nuget push ./nupkg/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} 30 | -------------------------------------------------------------------------------- /.github/workflows/publish-AsyncMonolith.MsSql-nuget-package.yaml: -------------------------------------------------------------------------------- 1 | name: Publish AsyncMonolith.MsSql nuget package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | working-directory: AsyncMonolith.MsSql 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | 16 | - name: Setup .NET Core 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 8.0.x 20 | 21 | - name: Build and Package 22 | run: | 23 | dotnet restore 24 | dotnet build -c Release 25 | dotnet pack --configuration Release --output nupkg 26 | 27 | - name: Publish to NuGet 28 | run: | 29 | dotnet nuget push ./nupkg/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} 30 | -------------------------------------------------------------------------------- /.github/workflows/publish-AsyncMonolith.MySql-nuget-package.yaml: -------------------------------------------------------------------------------- 1 | name: Publish AsyncMonolith.MySql nuget package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | working-directory: AsyncMonolith.MySql 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | 16 | - name: Setup .NET Core 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 8.0.x 20 | 21 | - name: Build and Package 22 | run: | 23 | dotnet restore 24 | dotnet build -c Release 25 | dotnet pack --configuration Release --output nupkg 26 | 27 | - name: Publish to NuGet 28 | run: | 29 | dotnet nuget push ./nupkg/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} 30 | -------------------------------------------------------------------------------- /.github/workflows/publish-AsyncMonolith.PostgreSql-nuget-package.yaml: -------------------------------------------------------------------------------- 1 | name: Publish AsyncMonolith.PostgreSql nuget package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | working-directory: AsyncMonolith.PostgreSql 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | 16 | - name: Setup .NET Core 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 8.0.x 20 | 21 | - name: Build and Package 22 | run: | 23 | dotnet restore 24 | dotnet build -c Release 25 | dotnet pack --configuration Release --output nupkg 26 | 27 | - name: Publish to NuGet 28 | run: | 29 | dotnet nuget push ./nupkg/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} 30 | -------------------------------------------------------------------------------- /.github/workflows/publish-AsyncMonolith.TestHelpers-nuget-package.yaml: -------------------------------------------------------------------------------- 1 | name: Publish AsyncMonolith.TestHelpers nuget package 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | defaults: 10 | run: 11 | working-directory: AsyncMonolith.TestHelpers 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | 16 | - name: Setup .NET Core 17 | uses: actions/setup-dotnet@v1 18 | with: 19 | dotnet-version: 8.0.x 20 | 21 | - name: Build and Package 22 | run: | 23 | dotnet restore 24 | dotnet build -c Release 25 | dotnet pack --configuration Release --output nupkg 26 | 27 | - name: Publish to NuGet 28 | run: | 29 | dotnet nuget push ./nupkg/*.nupkg --source https://api.nuget.org/v3/index.json --api-key ${{ secrets.NUGET_API_KEY }} 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | workflow_dispatch: 5 | 6 | jobs: 7 | build: 8 | runs-on: ubuntu-latest 9 | steps: 10 | - name: Checkout 11 | uses: actions/checkout@v3 12 | 13 | - name: Setup .NET 14 | uses: actions/setup-dotnet@v3 15 | with: 16 | dotnet-version: 8.0.x 17 | 18 | - name: Test 19 | run: dotnet test AsyncMonolith.Tests --verbosity normal -------------------------------------------------------------------------------- /.idea/.idea.AsyncMonolith/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Rider ignored files 5 | /modules.xml 6 | /contentModel.xml 7 | /projectSettingsUpdater.xml 8 | /.idea.AsyncMonolith.iml 9 | # Editor-based HTTP Client requests 10 | /httpRequests/ 11 | # Datasource local storage ignored files 12 | /dataSources/ 13 | /dataSources.local.xml 14 | -------------------------------------------------------------------------------- /.idea/.idea.AsyncMonolith/.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/.idea.AsyncMonolith/.idea/indexLayout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/.idea.AsyncMonolith/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /AsyncMonolith.Ef/AsyncMonolith.Ef.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | preview 9 | true 10 | AsyncMonolith.Ef 11 | 8.0.7 12 | Tim Jones 13 | Aptacode 14 | Entity Framework interface for AsyncMonolith 15 | https://github.com/Timmoth/AsyncMonolith 16 | https://github.com/Timmoth/AsyncMonolith 17 | git 18 | Monolith Messaging Scheduling Async 19 | AsyncMonolith.Ef 20 | logo.png 21 | True 22 | true 23 | $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage 24 | 25 | 26 | 27 | 28 | True 29 | \ 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | true 40 | AsyncMonolith.dll 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | -------------------------------------------------------------------------------- /AsyncMonolith.Ef/EfConsumerMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using AsyncMonolith.Utilities; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace AsyncMonolith.Ef; 7 | 8 | /// 9 | /// Fetches consumer messages using Entity Framework. 10 | /// 11 | public sealed class EfConsumerMessageFetcher : IConsumerMessageFetcher 12 | { 13 | private readonly IOptions _options; 14 | 15 | /// 16 | /// Initializes a new instance of the class. 17 | /// 18 | /// The options for AsyncMonolithSettings. 19 | public EfConsumerMessageFetcher(IOptions options) 20 | { 21 | _options = options; 22 | } 23 | 24 | /// 25 | /// Fetches consumer messages from the database. 26 | /// 27 | /// The DbSet of consumer messages. 28 | /// The current time. 29 | /// The cancellation token. 30 | /// A task that represents the asynchronous operation. The task result contains a list of consumer messages. 31 | public Task> Fetch(DbSet consumerSet, long currentTime, 32 | CancellationToken cancellationToken = default) 33 | { 34 | return consumerSet 35 | .Where(m => m.AvailableAfter <= currentTime) 36 | .OrderBy(m => m.CreatedAt) 37 | .Take(_options.Value.ProcessorBatchSize) 38 | .ToListAsync(cancellationToken); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /AsyncMonolith.Ef/EfScheduledMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Scheduling; 2 | using AsyncMonolith.Utilities; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Options; 5 | 6 | namespace AsyncMonolith.Ef; 7 | 8 | /// 9 | /// Fetches scheduled messages from the database using Entity Framework. 10 | /// 11 | public sealed class EfScheduledMessageFetcher : IScheduledMessageFetcher 12 | { 13 | private readonly IOptions _options; 14 | 15 | /// 16 | /// Initializes a new instance of the class. 17 | /// 18 | /// The options for AsyncMonolith. 19 | public EfScheduledMessageFetcher(IOptions options) 20 | { 21 | _options = options; 22 | } 23 | 24 | /// 25 | /// Fetches scheduled messages from the database. 26 | /// 27 | /// The DbSet of scheduled messages. 28 | /// The current time. 29 | /// The cancellation token. 30 | /// A task representing the asynchronous operation, containing the list of fetched scheduled messages. 31 | public Task> Fetch(DbSet set, long currentTime, 32 | CancellationToken cancellationToken = default) 33 | { 34 | return set.Where(m => m.AvailableAfter <= currentTime) 35 | .OrderBy(m => m.AvailableAfter) 36 | .Take(_options.Value.ProcessorBatchSize) 37 | .ToListAsync(cancellationToken); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /AsyncMonolith.Ef/StartupExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using AsyncMonolith.Consumers; 3 | using AsyncMonolith.Producers; 4 | using AsyncMonolith.Scheduling; 5 | using AsyncMonolith.Utilities; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace AsyncMonolith.Ef; 10 | 11 | /// 12 | /// Extension methods for configuring EF AsyncMonolith in the IServiceCollection. 13 | /// 14 | public static class StartupExtensions 15 | { 16 | /// 17 | /// Adds EF AsyncMonolith to the IServiceCollection. 18 | /// 19 | /// The DbContext type. 20 | /// The IServiceCollection to add the services to. 21 | /// The action used to configure the settings. 22 | /// A reference to this instance after the operation has completed. 23 | /// Thrown when the ConsumerMessageProcessorCount or ScheduledMessageProcessorCount is greater than 1. 24 | public static IServiceCollection AddEfAsyncMonolith( 25 | this IServiceCollection services, 26 | Action settings) where T : DbContext => 27 | AddEfAsyncMonolith(services, settings, AsyncMonolithSettings.Default); 28 | 29 | /// 30 | /// Adds EF AsyncMonolith to the IServiceCollection. 31 | /// 32 | /// The DbContext type. 33 | /// The IServiceCollection to add the services to. 34 | /// The assembly containing the DbContext. 35 | /// The optional AsyncMonolithSettings. 36 | /// A reference to this instance after the operation has completed. 37 | /// Thrown when the ConsumerMessageProcessorCount or ScheduledMessageProcessorCount is greater than 1. 38 | [Obsolete("This method is obsolete. Use the method that accepts an Action instead.")] 39 | public static IServiceCollection AddEfAsyncMonolith( 40 | this IServiceCollection services, 41 | Assembly assembly, 42 | AsyncMonolithSettings? settings = null) where T : DbContext => 43 | AddEfAsyncMonolith( 44 | services, 45 | configuration => configuration.RegisterTypesFromAssembly(assembly), 46 | settings ?? AsyncMonolithSettings.Default); 47 | 48 | private static IServiceCollection AddEfAsyncMonolith( 49 | this IServiceCollection services, 50 | Action configuration, 51 | AsyncMonolithSettings settings) where T : DbContext 52 | { 53 | configuration(settings); 54 | 55 | if (settings.ConsumerMessageProcessorCount > 1) 56 | { 57 | throw new ArgumentException( 58 | "AsyncMonolithSettings.ConsumerMessageProcessorCount can only be set to 1 when using 'DbType.Ef'."); 59 | } 60 | 61 | if (settings.ScheduledMessageProcessorCount > 1) 62 | { 63 | throw new ArgumentException( 64 | "AsyncMonolithSettings.ScheduledMessageProcessorCount can only be set to 1 when using 'DbType.Ef'."); 65 | } 66 | 67 | services.InternalAddAsyncMonolith(settings); 68 | services.AddScoped>(); 69 | services.AddSingleton(); 70 | services.AddSingleton(); 71 | return services; 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /AsyncMonolith.MariaDb/AsyncMonolith.MariaDb.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | preview 9 | true 10 | AsyncMonolith.MariaDb 11 | 8.0.7 12 | Tim Jones 13 | Aptacode 14 | MariaDb interface for AsyncMonolith 15 | https://github.com/Timmoth/AsyncMonolith 16 | https://github.com/Timmoth/AsyncMonolith 17 | git 18 | Monolith Messaging Scheduling Async 19 | AsyncMonolith.MariaDb 20 | logo.png 21 | True 22 | true 23 | $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage 24 | 25 | 26 | 27 | 28 | True 29 | \ 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | true 39 | AsyncMonolith.dll 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /AsyncMonolith.MariaDb/MariaDbConsumerMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using AsyncMonolith.Utilities; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Options; 5 | using MySqlConnector; 6 | 7 | namespace AsyncMonolith.MariaDb; 8 | 9 | /// 10 | /// Represents a message fetcher for consumer messages in MariaDb. 11 | /// 12 | public sealed class MariaDbConsumerMessageFetcher : IConsumerMessageFetcher 13 | { 14 | private const string MariaDb = @" 15 | SELECT * 16 | FROM consumer_messages 17 | WHERE available_after <= @currentTime 18 | ORDER BY created_at 19 | LIMIT @batchSize 20 | FOR UPDATE SKIP LOCKED"; 21 | 22 | private readonly IOptions _options; 23 | 24 | /// 25 | /// Initializes a new instance of the class. 26 | /// 27 | /// The options for AsyncMonolith settings. 28 | public MariaDbConsumerMessageFetcher(IOptions options) 29 | { 30 | _options = options; 31 | } 32 | 33 | /// 34 | /// Fetches consumer messages from the database. 35 | /// 36 | /// The DbSet of consumer messages. 37 | /// The current time. 38 | /// The cancellation token. 39 | /// A task that represents the asynchronous operation. The task result contains a list of consumer messages. 40 | public Task> Fetch(DbSet consumerSet, long currentTime, 41 | CancellationToken cancellationToken = default) 42 | { 43 | return consumerSet 44 | .FromSqlRaw(MariaDb, new MySqlParameter("@currentTime", currentTime), 45 | new MySqlParameter("@batchSize", _options.Value.ProcessorBatchSize)) 46 | .ToListAsync(cancellationToken); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /AsyncMonolith.MariaDb/MariaDbScheduledMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Scheduling; 2 | using AsyncMonolith.Utilities; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Options; 5 | using MySqlConnector; 6 | 7 | namespace AsyncMonolith.MariaDb; 8 | 9 | /// 10 | /// Fetches scheduled messages from MariaDb. 11 | /// 12 | public sealed class MariaDbScheduledMessageFetcher : IScheduledMessageFetcher 13 | { 14 | private const string MariaDb = @" 15 | SELECT * 16 | FROM scheduled_messages 17 | WHERE available_after <= @currentTime 18 | LIMIT @batchSize 19 | FOR UPDATE SKIP LOCKED"; 20 | 21 | private readonly IOptions _options; 22 | 23 | /// 24 | /// Initializes a new instance of the class. 25 | /// 26 | /// The options for AsyncMonolith. 27 | public MariaDbScheduledMessageFetcher(IOptions options) 28 | { 29 | _options = options; 30 | } 31 | 32 | /// 33 | /// Fetches scheduled messages from the database. 34 | /// 35 | /// The of scheduled messages. 36 | /// The current time. 37 | /// The cancellation token. 38 | /// A task that represents the asynchronous operation. The task result contains a list of fetched scheduled messages. 39 | public Task> Fetch(DbSet set, long currentTime, 40 | CancellationToken cancellationToken = default) 41 | { 42 | return set 43 | .FromSqlRaw(MariaDb, new MySqlParameter("@currentTime", currentTime), 44 | new MySqlParameter("@batchSize", _options.Value.ProcessorBatchSize)) 45 | .ToListAsync(cancellationToken); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /AsyncMonolith.MariaDb/StartupExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using AsyncMonolith.Consumers; 3 | using AsyncMonolith.Producers; 4 | using AsyncMonolith.Scheduling; 5 | using AsyncMonolith.Utilities; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace AsyncMonolith.MariaDb; 10 | /// 11 | /// AsyncMonolith MariaDb startup extensions 12 | /// 13 | public static class StartupExtensions 14 | { 15 | /// 16 | /// Adds MariaDb implementation of AsyncMonolith to the IServiceCollection. 17 | /// 18 | /// The DbContext type. 19 | /// The IServiceCollection to add the services to. 20 | /// The action used to configure the settings. 21 | /// A reference to this instance after the operation has completed. 22 | public static IServiceCollection AddMariaDbAsyncMonolith( 23 | this IServiceCollection services, 24 | Action settings) where T : DbContext => 25 | AddMariaDbAsyncMonolith(services, settings, AsyncMonolithSettings.Default); 26 | 27 | /// 28 | /// Adds MariaDb implementation of AsyncMonolith to the IServiceCollection. 29 | /// 30 | /// The DbContext type. 31 | /// The IServiceCollection to add the services to. 32 | /// The assembly containing the DbContext. 33 | /// Optional AsyncMonolith settings. 34 | /// A reference to this instance after the operation has completed. 35 | [Obsolete("This method is obsolete. Use the method that accepts an Action instead.")] 36 | public static IServiceCollection AddMariaDbAsyncMonolith( 37 | this IServiceCollection services, 38 | Assembly assembly, 39 | AsyncMonolithSettings? settings = null) where T : DbContext => 40 | AddMariaDbAsyncMonolith( 41 | services, 42 | configuration => configuration.RegisterTypesFromAssembly(assembly), 43 | settings ?? AsyncMonolithSettings.Default); 44 | 45 | private static IServiceCollection AddMariaDbAsyncMonolith( 46 | this IServiceCollection services, 47 | Action configuration, 48 | AsyncMonolithSettings settings) where T : DbContext 49 | { 50 | configuration(settings); 51 | 52 | services.InternalAddAsyncMonolith(settings); 53 | services.AddScoped>(); 54 | services.AddSingleton(); 55 | services.AddSingleton(); 56 | return services; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /AsyncMonolith.MsSql/AsyncMonolith.MsSql.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | preview 9 | true 10 | AsyncMonolith.MsSql 11 | 8.0.7 12 | Tim Jones 13 | Aptacode 14 | MsSql interface for AsyncMonolith 15 | https://github.com/Timmoth/AsyncMonolith 16 | https://github.com/Timmoth/AsyncMonolith 17 | git 18 | Monolith Messaging Scheduling Async 19 | AsyncMonolith.MsSql 20 | logo.png 21 | True 22 | true 23 | $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage 24 | 25 | 26 | 27 | 28 | True 29 | \ 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | true 39 | AsyncMonolith.dll 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /AsyncMonolith.MsSql/MsSqlConsumerMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using AsyncMonolith.Utilities; 3 | using Microsoft.Data.SqlClient; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace AsyncMonolith.MsSql; 8 | 9 | /// 10 | /// Represents a message fetcher for consuming messages from a SQL Server database. 11 | /// 12 | public sealed class MsSqlConsumerMessageFetcher : IConsumerMessageFetcher 13 | { 14 | private const string MsSql = @" 15 | SELECT TOP (@batchSize) * 16 | FROM consumer_messages WITH (ROWLOCK, READPAST) 17 | WHERE available_after <= @currentTime 18 | ORDER BY created_at"; 19 | 20 | private readonly IOptions _options; 21 | 22 | /// 23 | /// Initializes a new instance of the class. 24 | /// 25 | /// The options for the AsyncMonolith settings. 26 | public MsSqlConsumerMessageFetcher(IOptions options) 27 | { 28 | _options = options; 29 | } 30 | 31 | /// 32 | /// Fetches a batch of consumer messages from the database. 33 | /// 34 | /// The of consumer messages. 35 | /// The current time. 36 | /// The cancellation token. 37 | /// A task that represents the asynchronous operation. The task result contains the list of fetched consumer messages. 38 | public Task> Fetch(DbSet consumerSet, long currentTime, 39 | CancellationToken cancellationToken = default) 40 | { 41 | return consumerSet 42 | .FromSqlRaw(MsSql, new SqlParameter("@currentTime", currentTime), 43 | new SqlParameter("@batchSize", _options.Value.ProcessorBatchSize)) 44 | .ToListAsync(cancellationToken); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /AsyncMonolith.MsSql/MsSqlScheduledMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Scheduling; 2 | using AsyncMonolith.Utilities; 3 | using Microsoft.Data.SqlClient; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.Options; 6 | 7 | namespace AsyncMonolith.MsSql; 8 | 9 | /// 10 | /// Fetches scheduled messages from the MsSql database. 11 | /// 12 | public sealed class MsSqlScheduledMessageFetcher : IScheduledMessageFetcher 13 | { 14 | private const string MsSql = @" 15 | SELECT TOP (@batchSize) * 16 | FROM scheduled_messages WITH (ROWLOCK, READPAST) 17 | WHERE available_after <= @currentTime"; 18 | 19 | private readonly IOptions _options; 20 | 21 | /// 22 | /// Initializes a new instance of the class. 23 | /// 24 | /// The options for the AsyncMonolith settings. 25 | public MsSqlScheduledMessageFetcher(IOptions options) 26 | { 27 | _options = options; 28 | } 29 | 30 | /// 31 | /// Fetches scheduled messages from the database. 32 | /// 33 | /// The DbSet of scheduled messages. 34 | /// The current time. 35 | /// The cancellation token. 36 | /// A task that represents the asynchronous operation. The task result contains a list of scheduled messages. 37 | public Task> Fetch(DbSet set, long currentTime, 38 | CancellationToken cancellationToken = default) 39 | { 40 | return set 41 | .FromSqlRaw(MsSql, new SqlParameter("@currentTime", currentTime), 42 | new SqlParameter("@batchSize", _options.Value.ProcessorBatchSize)) 43 | .ToListAsync(cancellationToken); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /AsyncMonolith.MsSql/StartupExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using AsyncMonolith.Consumers; 3 | using AsyncMonolith.Producers; 4 | using AsyncMonolith.Scheduling; 5 | using AsyncMonolith.Utilities; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace AsyncMonolith.MsSql; 10 | 11 | /// 12 | /// AsyncMonolith MsSql startup extensions 13 | /// 14 | public static class StartupExtensions 15 | { 16 | /// 17 | /// Adds the MsSqlAsyncMonolith services to the IServiceCollection. 18 | /// 19 | /// The DbContext type. 20 | /// The IServiceCollection to add the services to. 21 | /// The action used to configure the settings. 22 | /// A reference to this instance after the operation has completed. 23 | public static IServiceCollection AddMsSqlAsyncMonolith( 24 | this IServiceCollection services, 25 | Action settings) where T : DbContext => 26 | AddMsSqlAsyncMonolith(services, settings, AsyncMonolithSettings.Default); 27 | 28 | /// 29 | /// Adds the MsSqlAsyncMonolith services to the IServiceCollection. 30 | /// 31 | /// The type of the DbContext. 32 | /// The IServiceCollection to add the services to. 33 | /// The assembly containing the DbContext and message handlers. 34 | /// The optional AsyncMonolithSettings. 35 | /// A reference to this instance after the operation has completed. 36 | [Obsolete("This method is obsolete. Use the method that accepts an Action instead.")] 37 | public static IServiceCollection AddMsSqlAsyncMonolith(this IServiceCollection services, Assembly assembly, 38 | AsyncMonolithSettings? settings = null) where T : DbContext => 39 | AddMsSqlAsyncMonolith( 40 | services, 41 | configuration => configuration.RegisterTypesFromAssembly(assembly), 42 | settings ?? AsyncMonolithSettings.Default); 43 | 44 | private static IServiceCollection AddMsSqlAsyncMonolith( 45 | this IServiceCollection services, 46 | Action configuration, 47 | AsyncMonolithSettings settings) where T : DbContext 48 | { 49 | configuration(settings); 50 | services.InternalAddAsyncMonolith(settings); 51 | services.AddScoped>(); 52 | services.AddSingleton(); 53 | services.AddSingleton(); 54 | return services; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /AsyncMonolith.MySql/AsyncMonolith.MySql.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | preview 9 | true 10 | AsyncMonolith.MySql 11 | 8.0.7 12 | Tim Jones 13 | Aptacode 14 | MySql interface for AsyncMonolith 15 | https://github.com/Timmoth/AsyncMonolith 16 | https://github.com/Timmoth/AsyncMonolith 17 | git 18 | Monolith Messaging Scheduling Async 19 | AsyncMonolith.MySql 20 | logo.png 21 | True 22 | true 23 | $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage 24 | 25 | 26 | 27 | 28 | True 29 | \ 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | true 39 | AsyncMonolith.dll 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /AsyncMonolith.MySql/MySqlConsumerMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using AsyncMonolith.Utilities; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Options; 5 | using MySqlConnector; 6 | 7 | namespace AsyncMonolith.MySql; 8 | 9 | /// 10 | /// Represents a message fetcher for consumer messages in MySQL. 11 | /// 12 | public sealed class MySqlConsumerMessageFetcher : IConsumerMessageFetcher 13 | { 14 | private const string MySql = @" 15 | SELECT * 16 | FROM consumer_messages 17 | WHERE available_after <= @currentTime 18 | ORDER BY created_at 19 | LIMIT @batchSize 20 | FOR UPDATE SKIP LOCKED"; 21 | 22 | private readonly IOptions _options; 23 | 24 | /// 25 | /// Initializes a new instance of the class. 26 | /// 27 | /// The options for AsyncMonolith settings. 28 | public MySqlConsumerMessageFetcher(IOptions options) 29 | { 30 | _options = options; 31 | } 32 | 33 | /// 34 | /// Fetches consumer messages from the database. 35 | /// 36 | /// The DbSet of consumer messages. 37 | /// The current time. 38 | /// The cancellation token. 39 | /// A task that represents the asynchronous operation. The task result contains a list of consumer messages. 40 | public Task> Fetch(DbSet consumerSet, long currentTime, 41 | CancellationToken cancellationToken = default) 42 | { 43 | return consumerSet 44 | .FromSqlRaw(MySql, new MySqlParameter("@currentTime", currentTime), 45 | new MySqlParameter("@batchSize", _options.Value.ProcessorBatchSize)) 46 | .ToListAsync(cancellationToken); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /AsyncMonolith.MySql/MySqlScheduledMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Scheduling; 2 | using AsyncMonolith.Utilities; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Options; 5 | using MySqlConnector; 6 | 7 | namespace AsyncMonolith.MySql; 8 | 9 | /// 10 | /// Fetches scheduled messages from MySQL database. 11 | /// 12 | public sealed class MySqlScheduledMessageFetcher : IScheduledMessageFetcher 13 | { 14 | private const string MySql = @" 15 | SELECT * 16 | FROM scheduled_messages 17 | WHERE available_after <= @currentTime 18 | LIMIT @batchSize 19 | FOR UPDATE SKIP LOCKED"; 20 | 21 | private readonly IOptions _options; 22 | 23 | /// 24 | /// Initializes a new instance of the class. 25 | /// 26 | /// The options for AsyncMonolith. 27 | public MySqlScheduledMessageFetcher(IOptions options) 28 | { 29 | _options = options; 30 | } 31 | 32 | /// 33 | /// Fetches scheduled messages from the database. 34 | /// 35 | /// The of scheduled messages. 36 | /// The current time. 37 | /// The cancellation token. 38 | /// A task that represents the asynchronous operation. The task result contains a list of fetched scheduled messages. 39 | public Task> Fetch(DbSet set, long currentTime, 40 | CancellationToken cancellationToken = default) 41 | { 42 | return set 43 | .FromSqlRaw(MySql, new MySqlParameter("@currentTime", currentTime), 44 | new MySqlParameter("@batchSize", _options.Value.ProcessorBatchSize)) 45 | .ToListAsync(cancellationToken); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /AsyncMonolith.MySql/StartupExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using AsyncMonolith.Consumers; 3 | using AsyncMonolith.Producers; 4 | using AsyncMonolith.Scheduling; 5 | using AsyncMonolith.Utilities; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace AsyncMonolith.MySql; 10 | /// 11 | /// AsyncMonolith MySql startup extensions 12 | /// 13 | public static class StartupExtensions 14 | { 15 | /// 16 | /// Adds MySql implementation of AsyncMonolith to the IServiceCollection. 17 | /// 18 | /// The DbContext type. 19 | /// The IServiceCollection to add the services to. 20 | /// The action used to configure the settings. 21 | /// A reference to this instance after the operation has completed. 22 | public static IServiceCollection AddMySqlAsyncMonolith( 23 | this IServiceCollection services, 24 | Action settings) where T : DbContext => 25 | AddMySqlAsyncMonolith(services, settings, AsyncMonolithSettings.Default); 26 | 27 | /// 28 | /// Adds MySql implementation of AsyncMonolith to the IServiceCollection. 29 | /// 30 | /// The DbContext type. 31 | /// The IServiceCollection to add the services to. 32 | /// The assembly containing the DbContext. 33 | /// Optional AsyncMonolith settings. 34 | /// A reference to this instance after the operation has completed. 35 | [Obsolete("This method is obsolete. Use the method that accepts an Action instead.")] 36 | public static IServiceCollection AddMySqlAsyncMonolith(this IServiceCollection services, Assembly assembly, 37 | AsyncMonolithSettings? settings = null) where T : DbContext => 38 | AddMySqlAsyncMonolith( 39 | services, 40 | configuration => configuration.RegisterTypesFromAssembly(assembly), 41 | settings ?? AsyncMonolithSettings.Default); 42 | 43 | private static IServiceCollection AddMySqlAsyncMonolith( 44 | this IServiceCollection services, 45 | Action configuration, 46 | AsyncMonolithSettings settings) where T : DbContext 47 | { 48 | configuration(settings); 49 | services.InternalAddAsyncMonolith(settings); 50 | services.AddScoped>(); 51 | services.AddSingleton(); 52 | services.AddSingleton(); 53 | return services; 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /AsyncMonolith.PostgreSql/AsyncMonolith.PostgreSql.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | preview 9 | true 10 | AsyncMonolith.PostgreSql 11 | 8.0.7 12 | Tim Jones 13 | Aptacode 14 | PostgreSql interface for AsyncMonolith 15 | https://github.com/Timmoth/AsyncMonolith 16 | https://github.com/Timmoth/AsyncMonolith 17 | git 18 | Monolith Messaging Scheduling Async 19 | AsyncMonolith.PostgreSql 20 | logo.png 21 | True 22 | true 23 | $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage 24 | 25 | 26 | 27 | 28 | True 29 | \ 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | true 40 | AsyncMonolith.dll 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | -------------------------------------------------------------------------------- /AsyncMonolith.PostgreSql/PostgreSqlConsumerMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using AsyncMonolith.Utilities; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Options; 5 | using Npgsql; 6 | 7 | namespace AsyncMonolith.PostgreSql; 8 | 9 | /// 10 | /// Represents a consumer message fetcher implementation for PostgreSQL. 11 | /// 12 | public sealed class PostgreSqlConsumerMessageFetcher : IConsumerMessageFetcher 13 | { 14 | private const string PgSql = @" 15 | SELECT * 16 | FROM consumer_messages 17 | WHERE available_after <= @currentTime 18 | ORDER BY created_at 19 | FOR UPDATE SKIP LOCKED 20 | LIMIT @batchSize"; 21 | 22 | private readonly IOptions _options; 23 | 24 | /// 25 | /// Initializes a new instance of the class. 26 | /// 27 | /// The options for the AsyncMonolith settings. 28 | public PostgreSqlConsumerMessageFetcher(IOptions options) 29 | { 30 | _options = options; 31 | } 32 | 33 | /// 34 | /// Fetches a batch of consumer messages from the PostgreSQL database. 35 | /// 36 | /// The DbSet of consumer messages. 37 | /// The current time. 38 | /// The cancellation token. 39 | /// A task that represents the asynchronous operation. The task result contains the list of fetched consumer messages. 40 | public Task> Fetch(DbSet consumerSet, long currentTime, 41 | CancellationToken cancellationToken = default) 42 | { 43 | return consumerSet 44 | .FromSqlRaw(PgSql, new NpgsqlParameter("@currentTime", currentTime), 45 | new NpgsqlParameter("@batchSize", _options.Value.ProcessorBatchSize)) 46 | .ToListAsync(cancellationToken); 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /AsyncMonolith.PostgreSql/PostgreSqlScheduledMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Scheduling; 2 | using AsyncMonolith.Utilities; 3 | using Microsoft.EntityFrameworkCore; 4 | using Microsoft.Extensions.Options; 5 | using Npgsql; 6 | 7 | namespace AsyncMonolith.PostgreSql; 8 | 9 | /// 10 | /// Fetches scheduled messages from a PostgreSQL database. 11 | /// 12 | public sealed class PostgreSqlScheduledMessageFetcher : IScheduledMessageFetcher 13 | { 14 | private const string PgSql = @" 15 | SELECT * 16 | FROM scheduled_messages 17 | WHERE available_after <= @currentTime 18 | FOR UPDATE SKIP LOCKED 19 | LIMIT @batchSize"; 20 | 21 | private readonly IOptions _options; 22 | 23 | /// 24 | /// Initializes a new instance of the class. 25 | /// 26 | /// The options for the AsyncMonolith. 27 | public PostgreSqlScheduledMessageFetcher(IOptions options) 28 | { 29 | _options = options; 30 | } 31 | 32 | /// 33 | /// Fetches scheduled messages from the database. 34 | /// 35 | /// The of scheduled messages. 36 | /// The current time. 37 | /// The cancellation token. 38 | /// A task that represents the asynchronous operation. The task result contains a list of fetched scheduled messages. 39 | public Task> Fetch(DbSet set, long currentTime, 40 | CancellationToken cancellationToken = default) 41 | { 42 | return set 43 | .FromSqlRaw(PgSql, new NpgsqlParameter("@currentTime", currentTime), 44 | new NpgsqlParameter("@batchSize", _options.Value.ProcessorBatchSize)) 45 | .ToListAsync(cancellationToken); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /AsyncMonolith.PostgreSql/StartupExtensions.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using AsyncMonolith.Consumers; 3 | using AsyncMonolith.Producers; 4 | using AsyncMonolith.Scheduling; 5 | using AsyncMonolith.Utilities; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace AsyncMonolith.PostgreSql; 10 | 11 | /// 12 | /// AsyncMonolith PostgreSql startup extensions 13 | /// 14 | public static class StartupExtensions 15 | { 16 | /// 17 | /// Adds the PostgreSql implementation of the AsyncMonolith to the service collection. 18 | /// 19 | /// The type of the DbContext. 20 | /// The service collection. 21 | /// The action used to configure the settings. 22 | /// A reference to this instance after the operation has completed. 23 | public static IServiceCollection AddPostgreSqlAsyncMonolith( 24 | this IServiceCollection services, 25 | Action settings) where T : DbContext => 26 | AddPostgreSqlAsyncMonolith(services, settings, AsyncMonolithSettings.Default); 27 | 28 | /// 29 | /// Adds the PostgreSql implementation of the AsyncMonolith to the service collection. 30 | /// 31 | /// The type of the DbContext. 32 | /// The service collection. 33 | /// The assembly containing the DbContext. 34 | /// The optional AsyncMonolith settings. 35 | /// A reference to this instance after the operation has completed. 36 | [Obsolete("This method is obsolete. Use the method that accepts an Action instead.")] 37 | public static IServiceCollection AddPostgreSqlAsyncMonolith( 38 | this IServiceCollection services, 39 | Assembly assembly, 40 | AsyncMonolithSettings? settings = null) where T : DbContext => 41 | AddPostgreSqlAsyncMonolith( 42 | services, 43 | configuration => configuration.RegisterTypesFromAssembly(assembly), 44 | settings ?? AsyncMonolithSettings.Default); 45 | 46 | private static IServiceCollection AddPostgreSqlAsyncMonolith( 47 | this IServiceCollection services, 48 | Action configuration, 49 | AsyncMonolithSettings settings) where T : DbContext 50 | { 51 | configuration(settings); 52 | services.InternalAddAsyncMonolith(settings); 53 | services.AddScoped>(); 54 | services.AddSingleton(); 55 | services.AddSingleton(); 56 | return services; 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /AsyncMonolith.TestHelpers/AsyncMonolith.TestHelpers.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | preview 9 | true 10 | AsyncMonolith.TestHelpers 11 | 8.0.5 12 | Tim Jones 13 | Aptacode 14 | Test helpers for AsyncMonolith 15 | https://github.com/Timmoth/AsyncMonolith 16 | https://github.com/Timmoth/AsyncMonolith 17 | git 18 | Monolith Messaging Scheduling Async 19 | AsyncMonolith.TestHelpers 20 | logo.png 21 | True 22 | true 23 | $(TargetsForTfmSpecificBuildOutput);CopyProjectReferencesToPackage 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | True 37 | \ 38 | 39 | 40 | 41 | 42 | 43 | true 44 | AsyncMonolith.dll 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | -------------------------------------------------------------------------------- /AsyncMonolith.TestHelpers/ConsumerTestBase.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using System.Globalization; 4 | using Microsoft.Extensions.Logging; 5 | using Microsoft.Extensions.Time.Testing; 6 | using Xunit.Abstractions; 7 | using Xunit; 8 | 9 | namespace AsyncMonolith.TestHelpers 10 | { 11 | /// 12 | /// Base class for consumer test classes. 13 | /// 14 | public abstract class ConsumerTestBase : IAsyncLifetime 15 | { 16 | private DateTime _startTime; 17 | private readonly LogLevel _logLevel; 18 | /// 19 | /// Fake time provider 20 | /// 21 | protected FakeTimeProvider FakeTime { get; private set; } = default!; 22 | 23 | /// 24 | /// Sets up the services for the test. 25 | /// 26 | /// The service collection. 27 | /// A task representing the asynchronous operation. 28 | protected abstract Task Setup(IServiceCollection services); 29 | 30 | private async Task Setup() 31 | { 32 | var services = new ServiceCollection(); 33 | services.AddLogging(b => 34 | { 35 | b.ClearProviders(); 36 | b.AddFilter(logLevel => logLevel >= _logLevel); 37 | b.AddXUnit(TestOutput); 38 | }); 39 | 40 | FakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2020-08-31T10:00:00.0000000Z")); 41 | services.AddSingleton(FakeTime); 42 | await Setup(services); 43 | 44 | return services.BuildServiceProvider(); 45 | } 46 | 47 | /// 48 | /// Initializes a new instance of the class. 49 | /// 50 | /// The test output helper. 51 | /// The log level. 52 | protected ConsumerTestBase(ITestOutputHelper testOutput, LogLevel logLevel = LogLevel.Information) 53 | { 54 | TestOutput = testOutput; 55 | _logLevel = logLevel; 56 | } 57 | 58 | /// 59 | /// Gets the test output helper. 60 | /// 61 | public ITestOutputHelper TestOutput { get; } 62 | 63 | /// 64 | /// Gets the service provider. 65 | /// 66 | public IServiceProvider Services { get; private set; } = default!; 67 | 68 | /// 69 | /// Initializes the test asynchronously. 70 | /// 71 | /// A task representing the asynchronous operation. 72 | public virtual async Task InitializeAsync() 73 | { 74 | _startTime = DateTime.Now; 75 | TestOutput.WriteLine($"[Lifecycle] Initialise {_startTime.ToString(CultureInfo.InvariantCulture)}"); 76 | Services = await Setup(); 77 | } 78 | 79 | /// 80 | /// Disposes the test asynchronously. 81 | /// 82 | /// A task representing the asynchronous operation. 83 | public virtual Task DisposeAsync() 84 | { 85 | TestOutput.WriteLine($"[Lifecycle] Dispose ({(DateTime.Now - _startTime).TotalSeconds}s)"); 86 | return Task.CompletedTask; 87 | } 88 | 89 | /// 90 | /// Processes the consumer message. 91 | /// 92 | /// The type of the consumer. 93 | /// The type of the payload. 94 | /// The payload. 95 | /// The cancellation token. 96 | /// A task representing the asynchronous operation. 97 | protected async Task Process(V payload, CancellationToken cancellationToken = default) where T : BaseConsumer where V : IConsumerPayload 98 | { 99 | using var scope = Services.CreateScope(); 100 | await TestConsumerMessageProcessor.Process(scope, payload, cancellationToken); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /AsyncMonolith.TestHelpers/FakeIdGenerator.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Utilities; 2 | 3 | namespace AsyncMonolith.TestHelpers; 4 | 5 | /// 6 | /// Represents a fake ID generator for testing purposes. 7 | /// 8 | public class FakeIdGenerator : IAsyncMonolithIdGenerator 9 | { 10 | /// 11 | /// Gets or sets the count of generated IDs. 12 | /// 13 | public int Count { get; private set; } 14 | 15 | /// 16 | /// Generates a fake ID. 17 | /// 18 | /// A string representing the generated ID. 19 | public string GenerateId() 20 | { 21 | return $"fake-id-{Count++}"; 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /AsyncMonolith.TestHelpers/FakeScheduleService.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using AsyncMonolith.Consumers; 3 | using AsyncMonolith.Scheduling; 4 | using AsyncMonolith.Utilities; 5 | using Cronos; 6 | 7 | namespace AsyncMonolith.TestHelpers; 8 | 9 | /// 10 | /// Represents a fake implementation of the interface for testing purposes. 11 | /// 12 | public sealed class FakeScheduleService : IScheduleService 13 | { 14 | private readonly IAsyncMonolithIdGenerator _fakeIdGenerator; 15 | private readonly TimeProvider _timeProvider; 16 | 17 | /// 18 | /// Initializes a new instance of the class. 19 | /// 20 | /// The time provider. 21 | /// The fake ID generator. 22 | public FakeScheduleService(TimeProvider timeProvider, IAsyncMonolithIdGenerator fakeIdGenerator) 23 | { 24 | _timeProvider = timeProvider; 25 | _fakeIdGenerator = fakeIdGenerator; 26 | } 27 | 28 | /// 29 | /// Gets or sets the list of created scheduled messages. 30 | /// 31 | public List CreatedScheduledMessages { get; set; } = new(); 32 | 33 | /// 34 | /// Gets or sets the list of deleted scheduled message tags. 35 | /// 36 | public List DeletedScheduledMessageTags { get; set; } = new(); 37 | 38 | /// 39 | /// Gets or sets the list of deleted scheduled message IDs. 40 | /// 41 | public List DeletedScheduledMessageIds { get; set; } = new(); 42 | 43 | /// 44 | /// Schedules a message for future execution. 45 | /// 46 | /// The type of the message payload. 47 | /// The message to schedule. 48 | /// The cron expression for scheduling the message. 49 | /// The timezone for scheduling the message. 50 | /// The optional tag for the scheduled message. 51 | /// The ID of the scheduled message. 52 | /// Thrown when the cron expression or timezone is invalid. 53 | public string Schedule(TK message, string chronExpression, string chronTimezone, string? tag = null) 54 | where TK : IConsumerPayload 55 | { 56 | var payload = JsonSerializer.Serialize(message); 57 | var id = _fakeIdGenerator.GenerateId(); 58 | 59 | var expression = CronExpression.Parse(chronExpression, CronFormat.IncludeSeconds); 60 | if (expression == null) 61 | { 62 | throw new InvalidOperationException( 63 | $"Couldn't determine scheduled message cron expression: '{chronExpression}'"); 64 | } 65 | 66 | var timezone = TimeZoneInfo.FindSystemTimeZoneById(chronTimezone); 67 | if (timezone == null) 68 | { 69 | throw new InvalidOperationException( 70 | $"Couldn't determine scheduled message timezone: '{chronTimezone}'"); 71 | } 72 | 73 | var next = expression.GetNextOccurrence(_timeProvider.GetUtcNow(), timezone); 74 | if (next == null) 75 | { 76 | throw new InvalidOperationException( 77 | $"Couldn't determine next scheduled message occurrence for cron expression: '{chronExpression}', timezone: '{chronTimezone}'"); 78 | } 79 | 80 | CreatedScheduledMessages.Add(new ScheduledMessage 81 | { 82 | Id = id, 83 | PayloadType = typeof(TK).Name, 84 | AvailableAfter = next.Value.ToUnixTimeSeconds(), 85 | Tag = tag, 86 | ChronExpression = chronExpression, 87 | ChronTimezone = chronTimezone, 88 | Payload = payload 89 | }); 90 | 91 | return id; 92 | } 93 | 94 | /// 95 | /// Deletes scheduled messages by tag. 96 | /// 97 | /// The tag of the scheduled messages to delete. 98 | /// The cancellation token. 99 | /// A task representing the asynchronous operation. 100 | public Task DeleteByTag(string tag, CancellationToken cancellationToken = default) 101 | { 102 | DeletedScheduledMessageTags.Add(tag); 103 | return Task.CompletedTask; 104 | } 105 | 106 | /// 107 | /// Deletes a scheduled message by ID. 108 | /// 109 | /// The ID of the scheduled message to delete. 110 | /// The cancellation token. 111 | /// A task representing the asynchronous operation. 112 | public Task DeleteById(string id, CancellationToken cancellationToken = default) 113 | { 114 | DeletedScheduledMessageIds.Add(id); 115 | return Task.CompletedTask; 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /AsyncMonolith.TestHelpers/SetupTestHelpers.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using AsyncMonolith.Producers; 3 | using AsyncMonolith.Scheduling; 4 | using AsyncMonolith.Utilities; 5 | using Microsoft.EntityFrameworkCore; 6 | using Microsoft.Extensions.DependencyInjection; 7 | 8 | namespace AsyncMonolith.TestHelpers; 9 | 10 | /// 11 | /// 12 | /// 13 | public static class SetupTestHelpers 14 | { 15 | /// 16 | /// Adds fake implementations of AsyncMonolith base services for testing purposes. 17 | /// 18 | /// The service collection. 19 | /// The action used to configure the settings. 20 | public static IServiceCollection AddFakeAsyncMonolithBaseServices( 21 | this IServiceCollection services, 22 | Action settings) => 23 | AddFakeAsyncMonolithBaseServices(services, settings, AsyncMonolithSettings.Default); 24 | 25 | /// 26 | /// Adds fake implementations of AsyncMonolith base services for testing purposes. 27 | /// 28 | /// The service collection. 29 | /// The assembly containing the consumers. 30 | /// Optional AsyncMonolith settings. 31 | [Obsolete("This method is obsolete. Use the method that accepts an Action instead.")] 32 | public static IServiceCollection AddFakeAsyncMonolithBaseServices( 33 | this IServiceCollection services, 34 | Assembly assembly, 35 | AsyncMonolithSettings? settings = null) => 36 | AddFakeAsyncMonolithBaseServices( 37 | services, 38 | configuration => configuration.RegisterTypesFromAssembly(assembly), 39 | settings ?? AsyncMonolithSettings.Default); 40 | 41 | /// 42 | /// Adds real implementations of AsyncMonolith base services for production use. 43 | /// 44 | /// The DbContext type. 45 | /// The service collection. 46 | /// The action used to configure the settings. 47 | public static IServiceCollection AddRealAsyncMonolithBaseServices( 48 | this IServiceCollection services, 49 | Action settings) where T : DbContext => 50 | AddRealAsyncMonolithBaseServices(services, settings, AsyncMonolithSettings.Default); 51 | 52 | /// 53 | /// Adds real implementations of AsyncMonolith base services for production use. 54 | /// 55 | /// The DbContext type. 56 | /// The service collection. 57 | /// The assembly containing the consumers. 58 | /// Optional AsyncMonolith settings. 59 | [Obsolete("This method is obsolete. Use the method that accepts an Action instead.")] 60 | public static IServiceCollection AddRealAsyncMonolithBaseServices( 61 | this IServiceCollection services, 62 | Assembly assembly, 63 | AsyncMonolithSettings? settings = null) where T : DbContext => 64 | AddRealAsyncMonolithBaseServices( 65 | services, 66 | configuration => configuration.RegisterTypesFromAssembly(assembly), 67 | settings ?? AsyncMonolithSettings.Default); 68 | 69 | private static IServiceCollection AddFakeAsyncMonolithBaseServices( 70 | this IServiceCollection services, 71 | Action configuration, 72 | AsyncMonolithSettings settings) 73 | { 74 | configuration(settings); 75 | services.InternalConfigureAsyncMonolithSettings(settings); 76 | services.InternalRegisterAsyncMonolithConsumers(settings); 77 | services.AddSingleton(new FakeIdGenerator()); 78 | services.AddScoped(); 79 | services.AddScoped(); 80 | return services; 81 | } 82 | 83 | private static IServiceCollection AddRealAsyncMonolithBaseServices( 84 | this IServiceCollection services, 85 | Action configuration, 86 | AsyncMonolithSettings settings) where T : DbContext 87 | { 88 | configuration(settings); 89 | services.InternalConfigureAsyncMonolithSettings(settings); 90 | services.InternalRegisterAsyncMonolithConsumers(settings); 91 | services.AddSingleton(new AsyncMonolithIdGenerator()); 92 | services.AddScoped>(); 93 | return services; 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /AsyncMonolith.Tests/AsyncMonolith.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | 8 | false 9 | true 10 | preview 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | all 30 | runtime; build; native; contentfiles; analyzers; buildtransitive 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /AsyncMonolith.Tests/ConsumerMessageFetcherTests.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using AsyncMonolith.Producers; 3 | using AsyncMonolith.Tests.Infra; 4 | using AsyncMonolith.Utilities; 5 | using FluentAssertions; 6 | using Microsoft.Extensions.DependencyInjection; 7 | 8 | namespace AsyncMonolith.Tests; 9 | 10 | public class ConsumerMessageFetcherTests : DbTestsBase 11 | { 12 | [Theory] 13 | [InlineData(DbType.Ef)] 14 | [InlineData(DbType.MySql)] 15 | [InlineData(DbType.MsSql)] 16 | [InlineData(DbType.PostgreSql)] 17 | [InlineData(DbType.MariaDb)] 18 | public async Task Fetch_Returns_Batch_Of_Messages(DbType dbType) 19 | { 20 | var dbContainer = GetTestDbContainer(dbType); 21 | 22 | try 23 | { 24 | // Given 25 | var settings = AsyncMonolithSettings.Default; 26 | var serviceProvider = await Setup(dbContainer, settings); 27 | var dbContext = serviceProvider.GetRequiredService(); 28 | var producer = serviceProvider.GetRequiredService(); 29 | var fetcher = serviceProvider.GetRequiredService(); 30 | 31 | var messages = new List(); 32 | for (var i = 0; i < 2 * settings.ProcessorBatchSize; i++) 33 | { 34 | messages.Add(new SingleConsumerMessage 35 | { 36 | Name = "test-name" 37 | }); 38 | } 39 | 40 | await producer.ProduceList(messages); 41 | await dbContext.SaveChangesAsync(); 42 | 43 | // When 44 | var dbMessages = await fetcher.Fetch(dbContext.ConsumerMessages, FakeTime.GetUtcNow().ToUnixTimeSeconds(), 45 | CancellationToken.None); 46 | 47 | // Then 48 | dbMessages.Count.Should().Be(settings.ProcessorBatchSize); 49 | } 50 | finally 51 | { 52 | await dbContainer.DisposeAsync(); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/DbTestsBase.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using AsyncMonolith.Scheduling; 3 | using AsyncMonolith.Utilities; 4 | using Microsoft.Extensions.DependencyInjection; 5 | using Microsoft.Extensions.Time.Testing; 6 | using System.Diagnostics; 7 | 8 | namespace AsyncMonolith.Tests.Infra; 9 | 10 | public abstract class DbTestsBase 11 | { 12 | protected FakeTimeProvider FakeTime = default!; 13 | protected TestConsumerInvocations TestConsumerInvocations = default!; 14 | 15 | protected async Task Setup(TestDbContainerBase dbContainer, AsyncMonolithSettings? settings = null) 16 | { 17 | await dbContainer.InitializeAsync(); 18 | 19 | var services = new ServiceCollection(); 20 | 21 | dbContainer.AddDb(services); 22 | 23 | var (fakeTime, invocations) = 24 | services.AddTestServices(dbContainer.DbType, settings ?? AsyncMonolithSettings.Default); 25 | TestConsumerInvocations = invocations; 26 | FakeTime = fakeTime; 27 | 28 | services.AddSingleton>(); 29 | services.AddSingleton>(); 30 | 31 | var serviceProvider = services.BuildServiceProvider(); 32 | 33 | using var scope = serviceProvider.CreateScope(); 34 | var dbContext = scope.ServiceProvider.GetRequiredService(); 35 | await dbContext.Database.EnsureCreatedAsync(); 36 | 37 | await dbContext.SaveChangesAsync(); 38 | 39 | return serviceProvider; 40 | } 41 | 42 | public Activity? GetActivity() 43 | { 44 | var activitySource = new ActivitySource("AsyncMonolith.Tests"); 45 | var listener = new ActivityListener 46 | { 47 | ShouldListenTo = (a) => a.Name == activitySource.Name, 48 | Sample = (ref ActivityCreationOptions options) => ActivitySamplingResult.AllDataAndRecorded, 49 | ActivityStarted = activity => Console.WriteLine($"Activity started: {activity.DisplayName}"), 50 | ActivityStopped = activity => Console.WriteLine($"Activity stopped: {activity.DisplayName}") 51 | }; 52 | ActivitySource.AddActivityListener(listener); 53 | return activitySource.StartActivity( 54 | "TestActivity", 55 | ActivityKind.Internal 56 | ); 57 | } 58 | public static IEnumerable GetTestDbContainers() 59 | { 60 | yield return new object[] { new MySqlTestDbContainer() }; 61 | yield return new object[] { new MsSqlTestDbContainer() }; 62 | yield return new object[] { new PostgreSqlTestDbContainer() }; 63 | yield return new object[] { new EfTestDbContainer() }; 64 | yield return new object[] { new MariaDbTestDbContainer() }; 65 | } 66 | 67 | public static TestDbContainerBase GetTestDbContainer(DbType dbType) 68 | { 69 | return dbType switch 70 | { 71 | DbType.Ef => new EfTestDbContainer(), 72 | DbType.MySql => new MySqlTestDbContainer(), 73 | DbType.MsSql => new MsSqlTestDbContainer(), 74 | DbType.PostgreSql => new PostgreSqlTestDbContainer(), 75 | DbType.MariaDb => new MariaDbTestDbContainer(), 76 | _ => throw new ArgumentOutOfRangeException(nameof(dbType), dbType, null) 77 | }; 78 | 79 | 80 | } 81 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/DbType.cs: -------------------------------------------------------------------------------- 1 | namespace AsyncMonolith.Tests.Infra; 2 | 3 | public enum DbType 4 | { 5 | Ef = 0, 6 | PostgreSql = 1, 7 | MySql = 2, 8 | MsSql = 3, 9 | MariaDb = 4, 10 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/EfTestDbContainer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Testcontainers.PostgreSql; 4 | 5 | namespace AsyncMonolith.Tests.Infra; 6 | 7 | public class EfTestDbContainer : TestDbContainerBase 8 | { 9 | public override async Task InitializeContainerAsync() 10 | { 11 | var container = new PostgreSqlBuilder().Build(); 12 | await container.StartAsync(); 13 | ConnectionString = container.GetConnectionString(); 14 | DbContainer = container; 15 | } 16 | 17 | public override void AddDb(ServiceCollection services) 18 | { 19 | services.AddDbContext((sp, options) => { options.UseNpgsql(ConnectionString, o => { }); } 20 | ); 21 | } 22 | 23 | public override DbType GetDbType() 24 | { 25 | return DbType.Ef; 26 | } 27 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/ExceptionConsumer.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | 3 | namespace AsyncMonolith.Tests.Infra; 4 | 5 | public class ExceptionConsumer : BaseConsumer 6 | { 7 | private readonly TestConsumerInvocations _consumerInvocations; 8 | 9 | public ExceptionConsumer(TestConsumerInvocations consumerInvocations) 10 | { 11 | _consumerInvocations = consumerInvocations; 12 | } 13 | 14 | public override Task Consume(ExceptionConsumerMessage message, CancellationToken cancellationToken) 15 | { 16 | _consumerInvocations.Increment(nameof(ExceptionConsumer)); 17 | throw new Exception(); 18 | } 19 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/ExceptionConsumer2Attempts.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | 3 | namespace AsyncMonolith.Tests.Infra; 4 | 5 | [ConsumerAttempts(2)] 6 | public class ExceptionConsumer2Attempts : BaseConsumer 7 | { 8 | private readonly TestConsumerInvocations _consumerInvocations; 9 | 10 | public ExceptionConsumer2Attempts(TestConsumerInvocations consumerInvocations) 11 | { 12 | _consumerInvocations = consumerInvocations; 13 | } 14 | 15 | public override Task Consume(ExceptionConsumer2AttemptsMessage message, CancellationToken cancellationToken) 16 | { 17 | _consumerInvocations.Increment(nameof(ExceptionConsumer2AttemptsMessage)); 18 | throw new Exception(); 19 | } 20 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/ExceptionConsumer2AttemptsMessage.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using AsyncMonolith.Consumers; 3 | 4 | namespace AsyncMonolith.Tests.Infra; 5 | 6 | public class ExceptionConsumer2AttemptsMessage : IConsumerPayload 7 | { 8 | [JsonPropertyName("name")] public required string Name { get; set; } 9 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/ExceptionConsumerMessage.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using AsyncMonolith.Consumers; 3 | 4 | namespace AsyncMonolith.Tests.Infra; 5 | 6 | public class ExceptionConsumerMessage : IConsumerPayload 7 | { 8 | [JsonPropertyName("name")] public required string Name { get; set; } 9 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/MariaDbTestDbContainer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Testcontainers.MariaDb; 4 | using Testcontainers.MsSql; 5 | 6 | namespace AsyncMonolith.Tests.Infra; 7 | 8 | public class MariaDbTestDbContainer : TestDbContainerBase 9 | { 10 | public override async Task InitializeContainerAsync() 11 | { 12 | var container = new MariaDbBuilder().Build(); 13 | await container.StartAsync(); 14 | ConnectionString = container.GetConnectionString(); 15 | DbContainer = container; 16 | } 17 | 18 | public override void AddDb(ServiceCollection services) 19 | { 20 | services.AddDbContext((sp, options) => 21 | { 22 | options.UseMySql(ConnectionString, ServerVersion.AutoDetect(ConnectionString)); 23 | } 24 | ); 25 | } 26 | 27 | public override DbType GetDbType() 28 | { 29 | return DbType.MariaDb; 30 | } 31 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/MsSqlTestDbContainer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Testcontainers.MsSql; 4 | 5 | namespace AsyncMonolith.Tests.Infra; 6 | 7 | public class MsSqlTestDbContainer : TestDbContainerBase 8 | { 9 | public override async Task InitializeContainerAsync() 10 | { 11 | var container = new MsSqlBuilder().Build(); 12 | await container.StartAsync(); 13 | ConnectionString = container.GetConnectionString(); 14 | DbContainer = container; 15 | } 16 | 17 | public override void AddDb(ServiceCollection services) 18 | { 19 | services.AddDbContext((sp, options) => { options.UseSqlServer(ConnectionString); } 20 | ); 21 | } 22 | 23 | public override DbType GetDbType() 24 | { 25 | return DbType.MsSql; 26 | } 27 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/MultiConsumer1.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | 3 | namespace AsyncMonolith.Tests.Infra; 4 | 5 | public class MultiConsumer1 : BaseConsumer 6 | { 7 | private readonly TestConsumerInvocations _consumerInvocations; 8 | 9 | public MultiConsumer1(TestConsumerInvocations consumerInvocations) 10 | { 11 | _consumerInvocations = consumerInvocations; 12 | } 13 | 14 | public override Task Consume(MultiConsumerMessage message, CancellationToken cancellationToken) 15 | { 16 | _consumerInvocations.Increment(nameof(MultiConsumer1)); 17 | return Task.CompletedTask; 18 | } 19 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/MultiConsumer2.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | 3 | namespace AsyncMonolith.Tests.Infra; 4 | 5 | public class MultiConsumer2 : BaseConsumer 6 | { 7 | private readonly TestConsumerInvocations _consumerInvocations; 8 | 9 | public MultiConsumer2(TestConsumerInvocations consumerInvocations) 10 | { 11 | _consumerInvocations = consumerInvocations; 12 | } 13 | 14 | public override Task Consume(MultiConsumerMessage message, CancellationToken cancellationToken) 15 | { 16 | _consumerInvocations.Increment(nameof(MultiConsumer2)); 17 | 18 | return Task.CompletedTask; 19 | } 20 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/MultiConsumerMessage.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using AsyncMonolith.Consumers; 3 | 4 | namespace AsyncMonolith.Tests.Infra; 5 | 6 | public class MultiConsumerMessage : IConsumerPayload 7 | { 8 | [JsonPropertyName("name")] public required string Name { get; set; } 9 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/MySqlTestDbContainer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Testcontainers.MySql; 4 | 5 | namespace AsyncMonolith.Tests.Infra; 6 | 7 | public class MySqlTestDbContainer : TestDbContainerBase 8 | { 9 | public override async Task InitializeContainerAsync() 10 | { 11 | var container = new MySqlBuilder().Build(); 12 | await container.StartAsync(); 13 | ConnectionString = container.GetConnectionString(); 14 | DbContainer = container; 15 | } 16 | 17 | public override void AddDb(ServiceCollection services) 18 | { 19 | services.AddDbContext((sp, options) => 20 | { 21 | options.UseMySql(ConnectionString, ServerVersion.AutoDetect(ConnectionString)); 22 | } 23 | ); 24 | } 25 | 26 | public override DbType GetDbType() 27 | { 28 | return DbType.MySql; 29 | } 30 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/PostgreSqlTestDbContainer.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Testcontainers.PostgreSql; 4 | 5 | namespace AsyncMonolith.Tests.Infra; 6 | 7 | public class PostgreSqlTestDbContainer : TestDbContainerBase 8 | { 9 | public override async Task InitializeContainerAsync() 10 | { 11 | var container = new PostgreSqlBuilder().Build(); 12 | await container.StartAsync(); 13 | ConnectionString = container.GetConnectionString(); 14 | DbContainer = container; 15 | } 16 | 17 | public override void AddDb(ServiceCollection services) 18 | { 19 | services.AddDbContext((sp, options) => { options.UseNpgsql(ConnectionString, o => { }); } 20 | ); 21 | } 22 | 23 | public override DbType GetDbType() 24 | { 25 | return DbType.PostgreSql; 26 | } 27 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/SingleConsumer.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | 3 | namespace AsyncMonolith.Tests.Infra; 4 | 5 | [ConsumerTimeout(1)] 6 | [ConsumerAttempts(2)] 7 | public class SingleConsumer : BaseConsumer 8 | { 9 | private readonly TestConsumerInvocations _consumerInvocations; 10 | 11 | public SingleConsumer(TestConsumerInvocations consumerInvocations) 12 | { 13 | _consumerInvocations = consumerInvocations; 14 | } 15 | 16 | public override Task Consume(SingleConsumerMessage message, CancellationToken cancellationToken) 17 | { 18 | _consumerInvocations.Increment(nameof(SingleConsumer)); 19 | return Task.CompletedTask; 20 | } 21 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/SingleConsumerMessage.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using AsyncMonolith.Consumers; 3 | 4 | namespace AsyncMonolith.Tests.Infra; 5 | 6 | public class SingleConsumerMessage : IConsumerPayload 7 | { 8 | [JsonPropertyName("name")] public required string Name { get; set; } 9 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/TestConsumerInvocations.cs: -------------------------------------------------------------------------------- 1 | namespace AsyncMonolith.Tests.Infra; 2 | 3 | public class TestConsumerInvocations 4 | { 5 | public Dictionary InvocationCounts { get; } = new(); 6 | 7 | public void Increment(string consumerName) 8 | { 9 | if (InvocationCounts.TryGetValue(consumerName, out var count)) 10 | { 11 | InvocationCounts[consumerName] = count + 1; 12 | } 13 | else 14 | { 15 | InvocationCounts[consumerName] = 1; 16 | } 17 | } 18 | 19 | public int GetInvocationCount(string consumerName) 20 | { 21 | return InvocationCounts.TryGetValue(consumerName, out var count) ? count : 0; 22 | } 23 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/TestDbContainerBase.cs: -------------------------------------------------------------------------------- 1 | using DotNet.Testcontainers.Containers; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace AsyncMonolith.Tests.Infra; 5 | 6 | public abstract class TestDbContainerBase : IAsyncLifetime 7 | { 8 | protected IContainer DbContainer { get; set; } = default!; 9 | protected string ConnectionString { get; set; } = default!; 10 | public DbType DbType => GetDbType(); 11 | 12 | public async Task InitializeAsync() 13 | { 14 | await InitializeContainerAsync(); 15 | } 16 | 17 | public async Task DisposeAsync() 18 | { 19 | await DbContainer.DisposeAsync().AsTask(); 20 | } 21 | 22 | public abstract Task InitializeContainerAsync(); 23 | 24 | public abstract void AddDb(ServiceCollection services); 25 | public abstract DbType GetDbType(); 26 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/TestDbContext.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using AsyncMonolith.Scheduling; 3 | using AsyncMonolith.Utilities; 4 | using Microsoft.EntityFrameworkCore; 5 | 6 | namespace AsyncMonolith.Tests.Infra; 7 | 8 | public class TestDbContext : DbContext 9 | { 10 | public TestDbContext(DbContextOptions options) : base(options) 11 | { 12 | } 13 | 14 | public DbSet ConsumerMessages { get; set; } = default!; 15 | public DbSet PoisonedMessages { get; set; } = default!; 16 | public DbSet ScheduledMessages { get; set; } = default!; 17 | 18 | protected override void OnModelCreating(ModelBuilder modelBuilder) 19 | { 20 | modelBuilder.ConfigureAsyncMonolith(); 21 | base.OnModelCreating(modelBuilder); 22 | } 23 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/TestServiceHelpers.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using AsyncMonolith.Consumers; 3 | using AsyncMonolith.Ef; 4 | using AsyncMonolith.MariaDb; 5 | using AsyncMonolith.MsSql; 6 | using AsyncMonolith.MySql; 7 | using AsyncMonolith.PostgreSql; 8 | using AsyncMonolith.Producers; 9 | using AsyncMonolith.Scheduling; 10 | using AsyncMonolith.TestHelpers; 11 | using AsyncMonolith.Utilities; 12 | using Microsoft.EntityFrameworkCore; 13 | using Microsoft.Extensions.DependencyInjection; 14 | using Microsoft.Extensions.Time.Testing; 15 | 16 | namespace AsyncMonolith.Tests.Infra; 17 | 18 | public static class TestServiceHelpers 19 | { 20 | public static void AddInMemoryDb(this ServiceCollection services) 21 | { 22 | var dbId = Guid.NewGuid().ToString(); 23 | services.AddDbContext((_, options) => { options.UseInMemoryDatabase(dbId); } 24 | ); 25 | } 26 | 27 | public static (FakeTimeProvider fakeTime, TestConsumerInvocations invocations) AddTestServices( 28 | this ServiceCollection services, DbType dbType, AsyncMonolithSettings settings) 29 | { 30 | var fakeTime = new FakeTimeProvider(DateTimeOffset.Parse("2020-08-31T10:00:00.0000000Z")); 31 | services.AddSingleton(fakeTime); 32 | services.AddLogging(); 33 | 34 | services.InternalConfigureAsyncMonolithSettings(settings); 35 | settings.RegisterTypesFromAssembly(Assembly.GetExecutingAssembly()); 36 | services.InternalRegisterAsyncMonolithConsumers(settings); 37 | services.AddSingleton(new FakeIdGenerator()); 38 | services.AddScoped>(); 39 | switch (dbType) 40 | { 41 | case DbType.Ef: 42 | services.AddScoped>(); 43 | services.AddSingleton(); 44 | services.AddSingleton(); 45 | break; 46 | case DbType.MySql: 47 | services.AddScoped>(); 48 | services.AddSingleton(); 49 | services.AddSingleton(); 50 | break; 51 | case DbType.MsSql: 52 | services.AddScoped>(); 53 | services.AddSingleton(); 54 | services.AddSingleton(); 55 | break; 56 | case DbType.PostgreSql: 57 | services.AddScoped>(); 58 | services.AddSingleton(); 59 | services.AddSingleton(); 60 | break; 61 | case DbType.MariaDb: 62 | services.AddScoped>(); 63 | services.AddSingleton(); 64 | services.AddSingleton(); 65 | break; 66 | } 67 | 68 | var invocations = new TestConsumerInvocations(); 69 | services.AddSingleton(invocations); 70 | 71 | return (fakeTime, invocations); 72 | } 73 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/TimeoutConsumer.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | 3 | namespace AsyncMonolith.Tests.Infra; 4 | 5 | [ConsumerTimeout(1)] 6 | public class TimeoutConsumer : BaseConsumer 7 | { 8 | private readonly TestConsumerInvocations _consumerInvocations; 9 | 10 | public TimeoutConsumer(TestConsumerInvocations consumerInvocations) 11 | { 12 | _consumerInvocations = consumerInvocations; 13 | } 14 | 15 | public override async Task Consume(TimeoutConsumerMessage message, CancellationToken cancellationToken) 16 | { 17 | _consumerInvocations.Increment(nameof(TimeoutConsumer)); 18 | await Task.Delay(TimeSpan.FromSeconds(message.Delay), cancellationToken); 19 | } 20 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/Infra/TimeoutConsumerMessage.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using AsyncMonolith.Consumers; 3 | 4 | namespace AsyncMonolith.Tests.Infra; 5 | 6 | public class TimeoutConsumerMessage : IConsumerPayload 7 | { 8 | [JsonPropertyName("delay")] public required int Delay { get; set; } 9 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/ScheduledMessageFetcherTests.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Scheduling; 2 | using AsyncMonolith.Tests.Infra; 3 | using AsyncMonolith.Utilities; 4 | using FluentAssertions; 5 | using Microsoft.Extensions.DependencyInjection; 6 | 7 | namespace AsyncMonolith.Tests; 8 | 9 | public class ScheduledMessageFetcherTests : DbTestsBase 10 | { 11 | [Theory] 12 | [InlineData(DbType.Ef)] 13 | [InlineData(DbType.MySql)] 14 | [InlineData(DbType.MsSql)] 15 | [InlineData(DbType.PostgreSql)] 16 | [InlineData(DbType.MariaDb)] 17 | public async Task Fetch_Returns_Batch_Of_Messages(DbType dbType) 18 | { 19 | var dbContainer = GetTestDbContainer(dbType); 20 | 21 | try 22 | { 23 | // Given 24 | var settings = AsyncMonolithSettings.Default; 25 | var serviceProvider = await Setup(dbContainer, settings); 26 | var dbContext = serviceProvider.GetRequiredService(); 27 | var fetcher = serviceProvider.GetRequiredService(); 28 | var idGenerator = serviceProvider.GetRequiredService(); 29 | 30 | for (var i = 0; i < 2 * settings.ProcessorBatchSize; i++) 31 | { 32 | dbContext.ScheduledMessages.Add(new ScheduledMessage 33 | { 34 | AvailableAfter = FakeTime.GetUtcNow().ToUnixTimeSeconds(), 35 | ChronExpression = "", 36 | ChronTimezone = "", 37 | Id = idGenerator.GenerateId(), 38 | Payload = "", 39 | PayloadType = "", 40 | Tag = "" 41 | }); 42 | } 43 | 44 | await dbContext.SaveChangesAsync(); 45 | 46 | // When 47 | var dbMessages = await fetcher.Fetch(dbContext.ScheduledMessages, FakeTime.GetUtcNow().ToUnixTimeSeconds(), 48 | CancellationToken.None); 49 | 50 | // Then 51 | dbMessages.Count.Should().Be(settings.ProcessorBatchSize); 52 | } 53 | finally 54 | { 55 | await dbContainer.DisposeAsync(); 56 | } 57 | } 58 | } -------------------------------------------------------------------------------- /AsyncMonolith.Tests/ScheduledMessageServiceTests.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using AsyncMonolith.Scheduling; 3 | using AsyncMonolith.TestHelpers; 4 | using AsyncMonolith.Tests.Infra; 5 | using FluentAssertions; 6 | using Microsoft.EntityFrameworkCore; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace AsyncMonolith.Tests; 10 | 11 | public class ScheduledMessageServiceTests : DbTestsBase 12 | { 13 | [Theory] 14 | [MemberData(nameof(GetTestDbContainers))] 15 | public async Task Schedule_Writes_Scheduled_Message(TestDbContainerBase dbContainer) 16 | { 17 | try 18 | { 19 | // Given 20 | var serviceProvider = await Setup(dbContainer); 21 | var consumerMessage = new SingleConsumerMessage 22 | { 23 | Name = "test-name" 24 | }; 25 | 26 | var scheduledMessageService = serviceProvider.GetRequiredService(); 27 | var dbContext = serviceProvider.GetRequiredService(); 28 | var tag = "test-tag"; 29 | 30 | // When 31 | scheduledMessageService.Schedule(consumerMessage, "* * * * * *", "UTC", tag); 32 | await dbContext.SaveChangesAsync(); 33 | 34 | // Then 35 | using var scope = serviceProvider.CreateScope(); 36 | { 37 | var postDbContext = serviceProvider.GetRequiredService(); 38 | var message = await postDbContext.AssertSingleScheduledMessage(consumerMessage); 39 | message.AvailableAfter.Should().Be(FakeTime.GetUtcNow().ToUnixTimeSeconds() + 1); 40 | message.Id.Should().Be("fake-id-0"); 41 | message.Tag.Should().BeEquivalentTo(tag); 42 | message.PayloadType = nameof(SingleConsumerMessage); 43 | message.Payload.Should().Be(JsonSerializer.Serialize(consumerMessage)); 44 | } 45 | } 46 | finally 47 | { 48 | await dbContainer.DisposeAsync(); 49 | } 50 | } 51 | 52 | [Theory] 53 | [MemberData(nameof(GetTestDbContainers))] 54 | public async Task DeleteByTag_Deletes_Scheduled_Messages(TestDbContainerBase dbContainer) 55 | { 56 | try 57 | { 58 | // Given 59 | var serviceProvider = await Setup(dbContainer); 60 | var consumerMessage1 = new SingleConsumerMessage 61 | { 62 | Name = "test-name" 63 | }; 64 | var consumerMessage2 = new MultiConsumerMessage 65 | { 66 | Name = "test-name" 67 | }; 68 | 69 | var scheduledMessageService = serviceProvider.GetRequiredService(); 70 | var dbContext = serviceProvider.GetRequiredService(); 71 | var tag = "test-tag"; 72 | scheduledMessageService.Schedule(consumerMessage1, "* * * * * *", "UTC", tag); 73 | scheduledMessageService.Schedule(consumerMessage2, "* * * * * *", "UTC", tag); 74 | await dbContext.SaveChangesAsync(); 75 | 76 | // When 77 | await scheduledMessageService.DeleteByTag("test-tag", CancellationToken.None); 78 | await dbContext.SaveChangesAsync(); 79 | 80 | // Then 81 | using var scope = serviceProvider.CreateScope(); 82 | { 83 | var postDbContext = serviceProvider.GetRequiredService(); 84 | var count = await postDbContext.ScheduledMessages.CountAsync(); 85 | count.Should().Be(0); 86 | } 87 | } 88 | finally 89 | { 90 | await dbContainer.DisposeAsync(); 91 | } 92 | } 93 | 94 | 95 | [Theory] 96 | [MemberData(nameof(GetTestDbContainers))] 97 | public async Task DeleteById_Deletes_Scheduled_Messages(TestDbContainerBase dbContainer) 98 | { 99 | try 100 | { 101 | // Given 102 | var serviceProvider = await Setup(dbContainer); 103 | var consumerMessage = new SingleConsumerMessage 104 | { 105 | Name = "test-name" 106 | }; 107 | 108 | var scheduledMessageService = serviceProvider.GetRequiredService(); 109 | var dbContext = serviceProvider.GetRequiredService(); 110 | var id = scheduledMessageService.Schedule(consumerMessage, "* * * * * *", "UTC"); 111 | await dbContext.SaveChangesAsync(); 112 | 113 | // When 114 | await scheduledMessageService.DeleteById(id, CancellationToken.None); 115 | await dbContext.SaveChangesAsync(); 116 | 117 | // Then 118 | using var scope = serviceProvider.CreateScope(); 119 | { 120 | var postDbContext = serviceProvider.GetRequiredService(); 121 | var count = await postDbContext.ScheduledMessages.CountAsync(); 122 | count.Should().Be(0); 123 | } 124 | } 125 | finally 126 | { 127 | await dbContainer.DisposeAsync(); 128 | } 129 | } 130 | } -------------------------------------------------------------------------------- /AsyncMonolith.sln.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | C:\Users\tjone\source\timmoth\AsyncMonolith\default.DotSettings 3 | ..\default.DotSettings 4 | True 5 | True 6 | 1 -------------------------------------------------------------------------------- /AsyncMonolith/AsyncMonolith.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | true 8 | preview 9 | true 10 | AsyncMonolith 11 | 8.0.7 12 | Tim Jones 13 | Aptacode 14 | Messaging library for monolithic dotnet apps 15 | https://github.com/Timmoth/AsyncMonolith 16 | https://github.com/Timmoth/AsyncMonolith 17 | git 18 | Monolith Messaging Scheduling Async 19 | Async Monolith 20 | logo.png 21 | True 22 | true 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | True 42 | \ 43 | 44 | 45 | -------------------------------------------------------------------------------- /AsyncMonolith/Consumers/BaseConsumer.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | 3 | namespace AsyncMonolith.Consumers; 4 | 5 | /// 6 | /// Base class for consumers. 7 | /// 8 | /// The type of the consumer payload. 9 | public abstract class BaseConsumer : IConsumer where T : IConsumerPayload 10 | { 11 | /// 12 | /// Internal method called by the processor to deserialize and process consumer payloads. 13 | /// 14 | /// The consumer message. 15 | /// The cancellation token. 16 | /// A task representing the asynchronous operation. 17 | public async Task Consume(ConsumerMessage message, CancellationToken cancellationToken = default) 18 | { 19 | var payload = JsonSerializer.Deserialize(message.Payload) ?? throw new Exception( 20 | $"Consumer: '{message.ConsumerType}' failed to deserialize payload: '{message.PayloadType}'"); 21 | 22 | await Consume(payload!, cancellationToken); 23 | } 24 | 25 | /// 26 | /// Consumes the payload. 27 | /// 28 | /// The consumer payload. 29 | /// The cancellation token. 30 | /// A task representing the asynchronous operation. 31 | public abstract Task Consume(T payload, CancellationToken cancellationToken = default); 32 | } -------------------------------------------------------------------------------- /AsyncMonolith/Consumers/ConsumerAttemptsAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace AsyncMonolith.Consumers; 2 | 3 | /// 4 | /// Consumer attempts attribute 5 | /// 6 | [AttributeUsage(AttributeTargets.Class, Inherited = false)] 7 | public sealed class ConsumerAttemptsAttribute : Attribute 8 | { 9 | /// 10 | /// Initializes a new instance of the class with the specified duration. 11 | /// 12 | /// The number of attempts before a message is placed in the poisoned table. 13 | public ConsumerAttemptsAttribute(int attempts) 14 | { 15 | Attempts = attempts; 16 | } 17 | 18 | /// 19 | /// The number of attempts before a message is placed in the poisoned table. 20 | /// 21 | public int Attempts { get; } 22 | } 23 | -------------------------------------------------------------------------------- /AsyncMonolith/Consumers/ConsumerMessage.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace AsyncMonolith.Consumers; 6 | 7 | /// 8 | /// Consumer Message model 9 | /// 10 | [Table("consumer_messages")] 11 | public sealed class ConsumerMessage 12 | { 13 | /// 14 | /// Gets or sets the ID of the consumer message. 15 | /// 16 | [Key] 17 | [JsonPropertyName("id")] 18 | [Column("id")] 19 | public required string Id { get; set; } 20 | 21 | /// 22 | /// Gets or sets the unix second timestamp when the consumer message was created. 23 | /// 24 | [JsonPropertyName("created_at")] 25 | [Column("created_at")] 26 | public required long CreatedAt { get; set; } 27 | 28 | /// 29 | /// Gets or sets the unix second timestamp at which that the consumer message becomes available for processing. 30 | /// 31 | [JsonPropertyName("available_after")] 32 | [Column("available_after")] 33 | public required long AvailableAfter { get; set; } 34 | 35 | /// 36 | /// Gets or sets the number of attempts made to process the consumer message. 37 | /// 38 | [JsonPropertyName("attempts")] 39 | [Column("attempts")] 40 | public required int Attempts { get; set; } 41 | 42 | /// 43 | /// Gets or sets the type of consumer that will process the message. 44 | /// 45 | [JsonPropertyName("consumer_type")] 46 | [Column("consumer_type")] 47 | public required string ConsumerType { get; set; } 48 | 49 | /// 50 | /// Gets or sets the type of payload contained in the consumer message. 51 | /// 52 | [JsonPropertyName("payload_type")] 53 | [Column("payload_type")] 54 | public required string PayloadType { get; set; } 55 | 56 | /// 57 | /// Gets or sets the payload of the consumer message. 58 | /// 59 | [JsonPropertyName("payload")] 60 | [Column("payload")] 61 | public required string Payload { get; set; } 62 | 63 | /// 64 | /// Gets or sets the ID of the insert operation that created the consumer message. 65 | /// Only one insert_id / consumer_type pair will be in the consumer_messages table at any time. 66 | /// 67 | [JsonPropertyName("insert_id")] 68 | [Column("insert_id")] 69 | public required string InsertId { get; set; } 70 | 71 | /// 72 | /// Gets or sets the trace Id of the activity that produced the consumer message. 73 | /// 74 | [JsonPropertyName("trace_id")] 75 | [Column("trace_id")] 76 | public required string? TraceId { get; set; } 77 | 78 | /// 79 | /// Gets or sets the span Id of the activity that produced the consumer message. 80 | /// 81 | [JsonPropertyName("span_id")] 82 | [Column("span_id")] 83 | public required string? SpanId { get; set; } 84 | } -------------------------------------------------------------------------------- /AsyncMonolith/Consumers/ConsumerMessageProcessorFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Hosting; 4 | 5 | namespace AsyncMonolith.Consumers; 6 | 7 | /// 8 | /// Represents a factory for creating and managing consumer message processors. 9 | /// 10 | /// The type of the DbContext used by the consumer message processors. 11 | public class ConsumerMessageProcessorFactory : IHostedService where T : DbContext 12 | { 13 | private readonly List _hostedServices; 14 | private readonly int _instances; 15 | private readonly IServiceProvider _serviceProvider; 16 | 17 | /// 18 | /// Initializes a new instance of the class. 19 | /// 20 | /// The service provider used to create instances of consumer message processors. 21 | /// The number of instances of consumer message processors to create. 22 | public ConsumerMessageProcessorFactory(IServiceProvider serviceProvider, int instances) 23 | { 24 | _serviceProvider = serviceProvider; 25 | _instances = instances; 26 | _hostedServices = new List(); 27 | } 28 | 29 | /// 30 | /// Starts the consumer message processors asynchronously. 31 | /// 32 | /// The cancellation token to stop the operation. 33 | public async Task StartAsync(CancellationToken cancellationToken) 34 | { 35 | for (var i = 0; i < _instances; i++) 36 | { 37 | var hostedService = ActivatorUtilities.CreateInstance>(_serviceProvider); 38 | _hostedServices.Add(hostedService); 39 | await hostedService.StartAsync(cancellationToken); 40 | } 41 | } 42 | 43 | /// 44 | /// Stops the consumer message processors asynchronously. 45 | /// 46 | /// The cancellation token to stop the operation. 47 | public async Task StopAsync(CancellationToken cancellationToken) 48 | { 49 | foreach (var hostedService in _hostedServices) 50 | { 51 | await hostedService.StopAsync(cancellationToken); 52 | } 53 | } 54 | } -------------------------------------------------------------------------------- /AsyncMonolith/Consumers/ConsumerTimeoutAttribute.cs: -------------------------------------------------------------------------------- 1 | namespace AsyncMonolith.Consumers; 2 | 3 | /// 4 | /// Consumer timeout attribute 5 | /// 6 | [AttributeUsage(AttributeTargets.Class, Inherited = false)] 7 | public sealed class ConsumerTimeoutAttribute : Attribute 8 | { 9 | /// 10 | /// Initializes a new instance of the class with the specified duration. 11 | /// 12 | /// The duration of the consumer timeout. 13 | public ConsumerTimeoutAttribute(int duration) 14 | { 15 | Duration = duration; 16 | } 17 | 18 | /// 19 | /// Gets the duration of the consumer timeout. 20 | /// 21 | public int Duration { get; } 22 | } 23 | -------------------------------------------------------------------------------- /AsyncMonolith/Consumers/IConsumer.cs: -------------------------------------------------------------------------------- 1 | namespace AsyncMonolith.Consumers; 2 | 3 | /// 4 | /// Interface for Consumers 5 | /// 6 | public interface IConsumer 7 | { 8 | /// 9 | /// Consumes the given message. 10 | /// 11 | /// The consumer message to be consumed. 12 | /// The cancellation token. 13 | /// A task representing the asynchronous operation. 14 | public Task Consume(ConsumerMessage message, CancellationToken cancellationToken); 15 | } 16 | -------------------------------------------------------------------------------- /AsyncMonolith/Consumers/IConsumerMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace AsyncMonolith.Consumers; 4 | 5 | /// 6 | /// Represents an interface for fetching consumer messages. 7 | /// 8 | public interface IConsumerMessageFetcher 9 | { 10 | /// 11 | /// Fetches consumer messages. 12 | /// 13 | /// The DbSet of consumer messages. 14 | /// The current time as unix timestamp seconds. 15 | /// The cancellation token. 16 | /// A task that represents the asynchronous operation. The task result contains a list of consumer messages. 17 | public Task> Fetch(DbSet consumerSet, long currentTime, 18 | CancellationToken cancellationToken = default); 19 | } -------------------------------------------------------------------------------- /AsyncMonolith/Consumers/IConsumerPayload.cs: -------------------------------------------------------------------------------- 1 | namespace AsyncMonolith.Consumers; 2 | 3 | /// 4 | /// Interface for consumer payloads 5 | /// 6 | public interface IConsumerPayload 7 | { 8 | } -------------------------------------------------------------------------------- /AsyncMonolith/Consumers/PoisonedMessage.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | using System.Text.Json.Serialization; 4 | 5 | namespace AsyncMonolith.Consumers; 6 | 7 | /// 8 | /// Poisoned message model 9 | /// 10 | [Table("poisoned_messages")] 11 | public sealed class PoisonedMessage 12 | { 13 | /// 14 | /// Gets or sets the ID of the consumer message. 15 | /// 16 | [Key] 17 | [JsonPropertyName("id")] 18 | [Column("id")] 19 | public required string Id { get; set; } 20 | 21 | /// 22 | /// Gets or sets the unix second timestamp when the consumer message was created. 23 | /// 24 | [JsonPropertyName("created_at")] 25 | [Column("created_at")] 26 | public required long CreatedAt { get; set; } 27 | 28 | /// 29 | /// Gets or sets the unix second timestamp at which that the consumer message becomes available for processing. 30 | /// 31 | [JsonPropertyName("available_after")] 32 | [Column("available_after")] 33 | public required long AvailableAfter { get; set; } 34 | 35 | /// 36 | /// Gets or sets the number of attempts made to process the consumer message. 37 | /// 38 | [JsonPropertyName("attempts")] 39 | [Column("attempts")] 40 | public required int Attempts { get; set; } 41 | 42 | /// 43 | /// Gets or sets the type of consumer that will process the message. 44 | /// 45 | [JsonPropertyName("consumer_type")] 46 | [Column("consumer_type")] 47 | public required string ConsumerType { get; set; } 48 | 49 | /// 50 | /// Gets or sets the type of payload contained in the consumer message. 51 | /// 52 | [JsonPropertyName("payload_type")] 53 | [Column("payload_type")] 54 | public required string PayloadType { get; set; } 55 | 56 | /// 57 | /// Gets or sets the payload of the consumer message. 58 | /// 59 | [JsonPropertyName("payload")] 60 | [Column("payload")] 61 | public required string Payload { get; set; } 62 | 63 | /// 64 | /// Gets or sets the ID of the insert operation that created the consumer message. 65 | /// Only one insert_id / consumer_type pair will be in the consumer_messages table at any time. 66 | /// 67 | [JsonPropertyName("insert_id")] 68 | [Column("insert_id")] 69 | public required string InsertId { get; set; } 70 | 71 | /// 72 | /// Gets or sets the trace Id of the activity that produced the consumer message. 73 | /// 74 | [JsonPropertyName("trace_id")] 75 | [Column("trace_id")] 76 | public required string? TraceId { get; set; } 77 | 78 | /// 79 | /// Gets or sets the span Id of the activity that produced the consumer message. 80 | /// 81 | [JsonPropertyName("span_id")] 82 | [Column("span_id")] 83 | public required string? SpanId { get; set; } 84 | } -------------------------------------------------------------------------------- /AsyncMonolith/Producers/IProducerService.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using AsyncMonolith.Scheduling; 3 | 4 | namespace AsyncMonolith.Producers; 5 | 6 | /// 7 | /// Interface for producing messages. 8 | /// 9 | public interface IProducerService 10 | { 11 | /// 12 | /// Produces a single message 13 | /// 14 | /// The type of the message. 15 | /// The message to produce. 16 | /// The time in seconds after which the message should be available for consumption. 17 | /// The insert ID for the message. 18 | /// Cancellation Token 19 | /// A task representing the asynchronous operation. 20 | public Task Produce(TK message, long? availableAfter = null, string? insertId = null, 21 | CancellationToken cancellationToken = default) 22 | where TK : IConsumerPayload; 23 | 24 | /// 25 | /// Produces a list of messages of type TK. 26 | /// 27 | /// The type of the messages. 28 | /// The list of messages to produce. 29 | /// The time in seconds after which the messages should be available for consumption. 30 | /// Cancellation Token 31 | /// A task representing the asynchronous operation. 32 | public Task ProduceList(List messages, long? availableAfter = null, 33 | CancellationToken cancellationToken = default) where TK : IConsumerPayload; 34 | 35 | /// 36 | /// Produces a scheduled message. 37 | /// 38 | /// The scheduled message to produce. 39 | public void Produce(ScheduledMessage message); 40 | } -------------------------------------------------------------------------------- /AsyncMonolith/Scheduling/IScheduleService.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | 3 | namespace AsyncMonolith.Scheduling; 4 | 5 | /// 6 | /// Service for scheduling messages. 7 | /// 8 | public interface IScheduleService 9 | { 10 | /// 11 | /// Schedules a message. 12 | /// 13 | /// The type of the message. 14 | /// The message to schedule. 15 | /// The cron expression. 16 | /// The timezone for the cron expression. 17 | /// The optional tag for the scheduled message. 18 | /// The ID of the scheduled message. 19 | public string Schedule(TK message, string chronExpression, string chronTimezone, string? tag = null) 20 | where TK : IConsumerPayload; 21 | 22 | /// 23 | /// Deletes scheduled messages by tag. 24 | /// 25 | /// The tag of the scheduled messages to delete. 26 | /// The cancellation token. 27 | /// A task representing the asynchronous operation. 28 | public Task DeleteByTag(string tag, CancellationToken cancellationToken = default); 29 | 30 | /// 31 | /// Deletes a scheduled message by ID. 32 | /// 33 | /// The ID of the scheduled message to delete. 34 | /// The cancellation token. 35 | /// A task representing the asynchronous operation. 36 | public Task DeleteById(string id, CancellationToken cancellationToken = default); 37 | } -------------------------------------------------------------------------------- /AsyncMonolith/Scheduling/IScheduledMessageFetcher.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | 3 | namespace AsyncMonolith.Scheduling; 4 | 5 | /// 6 | /// Interface for fetching scheduled messages. 7 | /// 8 | public interface IScheduledMessageFetcher 9 | { 10 | /// 11 | /// Fetches scheduled messages. 12 | /// 13 | /// The DbSet of scheduled messages. 14 | /// The current time. 15 | /// The cancellation token. 16 | /// A list of fetched scheduled messages. 17 | public Task> Fetch(DbSet set, long currentTime, 18 | CancellationToken cancellationToken = default); 19 | } -------------------------------------------------------------------------------- /AsyncMonolith/Scheduling/ScheduleService.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json; 2 | using AsyncMonolith.Consumers; 3 | using AsyncMonolith.Utilities; 4 | using Cronos; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | namespace AsyncMonolith.Scheduling; 8 | 9 | /// 10 | /// Service for scheduling messages. 11 | /// 12 | /// The type of DbContext. 13 | public sealed class ScheduleService : IScheduleService where T : DbContext 14 | { 15 | private readonly T _dbContext; 16 | private readonly IAsyncMonolithIdGenerator _idGenerator; 17 | private readonly TimeProvider _timeProvider; 18 | 19 | /// 20 | /// Initializes a new instance of the class. 21 | /// 22 | /// The time provider. 23 | /// The DbContext. 24 | /// The ID generator. 25 | public ScheduleService(TimeProvider timeProvider, T dbContext, IAsyncMonolithIdGenerator idGenerator) 26 | { 27 | _timeProvider = timeProvider; 28 | _dbContext = dbContext; 29 | _idGenerator = idGenerator; 30 | } 31 | 32 | /// 33 | /// Schedules a message. 34 | /// 35 | /// The type of the message. 36 | /// The message to schedule. 37 | /// The cron expression for scheduling. 38 | /// The timezone for scheduling. 39 | /// The optional tag for the scheduled message. 40 | /// The ID of the scheduled message. 41 | public string Schedule(TK message, string chronExpression, string chronTimezone, string? tag = null) 42 | where TK : IConsumerPayload 43 | { 44 | var payload = JsonSerializer.Serialize(message); 45 | var id = _idGenerator.GenerateId(); 46 | 47 | var expression = CronExpression.Parse(chronExpression, CronFormat.IncludeSeconds); 48 | if (expression == null) 49 | { 50 | throw new InvalidOperationException( 51 | $"Couldn't determine scheduled message chron expression: '{chronExpression}'"); 52 | } 53 | 54 | var timezone = TimeZoneInfo.FindSystemTimeZoneById(chronTimezone); 55 | if (timezone == null) 56 | { 57 | throw new InvalidOperationException($"Couldn't determine scheduled message timezone: '{chronTimezone}'"); 58 | } 59 | 60 | var next = expression.GetNextOccurrence(_timeProvider.GetUtcNow(), timezone); 61 | if (next == null) 62 | { 63 | throw new InvalidOperationException( 64 | $"Couldn't determine next scheduled message occurrence for chron expression: '{chronExpression}', timezone: '{chronTimezone}'"); 65 | } 66 | 67 | _dbContext.Set().Add(new ScheduledMessage 68 | { 69 | Id = id, 70 | PayloadType = typeof(TK).Name, 71 | AvailableAfter = next.Value.ToUnixTimeSeconds(), 72 | Tag = tag, 73 | ChronExpression = chronExpression, 74 | ChronTimezone = chronTimezone, 75 | Payload = payload 76 | }); 77 | 78 | return id; 79 | } 80 | 81 | /// 82 | /// Deletes scheduled messages by tag. 83 | /// 84 | /// The tag of the scheduled messages to delete. 85 | /// The cancellation token. 86 | /// A task representing the asynchronous operation. 87 | public async Task DeleteByTag(string tag, CancellationToken cancellationToken = default) 88 | { 89 | var set = _dbContext.Set(); 90 | var messages = await set.Where(t => t.Tag == tag).ToListAsync(cancellationToken); 91 | set.RemoveRange(messages); 92 | } 93 | 94 | /// 95 | /// Deletes a scheduled message by ID. 96 | /// 97 | /// The ID of the scheduled message to delete. 98 | /// The cancellation token. 99 | /// A task representing the asynchronous operation. 100 | public async Task DeleteById(string id, CancellationToken cancellationToken = default) 101 | { 102 | var set = _dbContext.Set(); 103 | var message = await set.Where(t => t.Id == id).FirstOrDefaultAsync(cancellationToken); 104 | if (message != null) 105 | { 106 | set.Remove(message); 107 | } 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /AsyncMonolith/Scheduling/ScheduledMessage.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | using System.Text.Json; 4 | using System.Text.Json.Serialization; 5 | using AsyncMonolith.Consumers; 6 | using Cronos; 7 | 8 | namespace AsyncMonolith.Scheduling; 9 | 10 | /// 11 | /// Represents a scheduled message. 12 | /// 13 | [Table("scheduled_messages")] 14 | public class ScheduledMessage 15 | { 16 | /// 17 | /// Gets or sets the ID of the scheduled message. 18 | /// 19 | [Key] 20 | [JsonPropertyName("id")] 21 | [Column("id")] 22 | public required string Id { get; set; } 23 | 24 | /// 25 | /// Gets or sets the tag of the scheduled message. 26 | /// 27 | [JsonPropertyName("tag")] 28 | [Column("tag")] 29 | public required string? Tag { get; set; } 30 | 31 | /// 32 | /// Gets or sets the unix second timestamp after which the scheduled message payload will be enqueued. 33 | /// 34 | [JsonPropertyName("available_after")] 35 | [Column("available_after")] 36 | public required long AvailableAfter { get; set; } 37 | 38 | /// 39 | /// Gets or sets the cron expression of the scheduled message. 40 | /// 41 | [JsonPropertyName("chron_expression")] 42 | [Column("chron_expression")] 43 | public required string ChronExpression { get; set; } 44 | 45 | /// 46 | /// Gets or sets the cron timezone of the scheduled message. 47 | /// 48 | [JsonPropertyName("chron_timezone")] 49 | [Column("chron_timezone")] 50 | public required string ChronTimezone { get; set; } 51 | 52 | /// 53 | /// Gets or sets the payload type of the scheduled message. 54 | /// 55 | [JsonPropertyName("payload_type")] 56 | [Column("payload_type")] 57 | public required string PayloadType { get; set; } 58 | 59 | /// 60 | /// Gets or sets the payload of the scheduled message. 61 | /// 62 | [JsonPropertyName("payload")] 63 | [Column("payload")] 64 | public required string Payload { get; set; } 65 | 66 | /// 67 | /// Gets the next occurrence of the scheduled message as a unix second timestamp. 68 | /// 69 | /// The time provider. 70 | /// The next occurrence of the scheduled message in Unix timestamp format. 71 | public long GetNextOccurrence(TimeProvider timeProvider) 72 | { 73 | var expression = CronExpression.Parse(ChronExpression, CronFormat.IncludeSeconds); 74 | if (expression == null) 75 | { 76 | throw new InvalidOperationException( 77 | $"Couldn't determine scheduled message chron expression: '{ChronExpression}'"); 78 | } 79 | 80 | var timezone = TimeZoneInfo.FindSystemTimeZoneById(ChronTimezone); 81 | if (timezone == null) 82 | { 83 | throw new InvalidOperationException($"Couldn't determine scheduled message timezone: '{ChronTimezone}'"); 84 | } 85 | 86 | var next = expression.GetNextOccurrence(timeProvider.GetUtcNow(), timezone); 87 | if (next == null) 88 | { 89 | throw new InvalidOperationException("Couldn't determine next scheduled message occurrence"); 90 | } 91 | 92 | return next.Value.ToUnixTimeSeconds(); 93 | } 94 | 95 | /// 96 | /// Updates the schedule of the scheduled message. 97 | /// 98 | /// The new cron expression. 99 | /// The new cron timezone. 100 | /// The time provider. 101 | public void UpdateSchedule(string chronExpression, string chronTimezone, TimeProvider timeProvider) 102 | { 103 | ChronExpression = chronExpression; 104 | ChronTimezone = chronTimezone; 105 | AvailableAfter = GetNextOccurrence(timeProvider); 106 | } 107 | 108 | /// 109 | /// Updates the payload of the scheduled message. 110 | /// 111 | /// The type of the payload. 112 | /// The payload message. 113 | public void UpdatePayload(TK message) where TK : IConsumerPayload 114 | { 115 | var payload = JsonSerializer.Serialize(message); 116 | Payload = payload; 117 | PayloadType = typeof(TK).Name; 118 | } 119 | } -------------------------------------------------------------------------------- /AsyncMonolith/Scheduling/ScheduledMessageProcessor.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using AsyncMonolith.Producers; 3 | using AsyncMonolith.Utilities; 4 | using Microsoft.EntityFrameworkCore; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Microsoft.Extensions.Hosting; 7 | using Microsoft.Extensions.Logging; 8 | using Microsoft.Extensions.Options; 9 | 10 | namespace AsyncMonolith.Scheduling; 11 | 12 | /// 13 | /// Represents a background service for processing scheduled messages. 14 | /// 15 | /// The type of the database context. 16 | public sealed class ScheduledMessageProcessor : BackgroundService where T : DbContext 17 | { 18 | private readonly ILogger> _logger; 19 | private readonly IScheduledMessageFetcher _messageFetcher; 20 | private readonly IOptions _options; 21 | private readonly IServiceScopeFactory _scopeFactory; 22 | private readonly TimeProvider _timeProvider; 23 | 24 | /// 25 | /// Initializes a new instance of the class. 26 | /// 27 | /// The logger. 28 | /// The time provider. 29 | /// The options. 30 | /// The service scope factory. 31 | /// The scheduled message fetcher. 32 | public ScheduledMessageProcessor(ILogger> logger, 33 | TimeProvider timeProvider, IOptions options, IServiceScopeFactory scopeFactory, 34 | IScheduledMessageFetcher messageFetcher) 35 | { 36 | _logger = logger; 37 | _timeProvider = timeProvider; 38 | _options = options; 39 | _scopeFactory = scopeFactory; 40 | _messageFetcher = messageFetcher; 41 | } 42 | 43 | /// 44 | /// Executes the background service asynchronously. 45 | /// 46 | /// The cancellation token to stop the execution. 47 | protected override async Task ExecuteAsync(CancellationToken stoppingToken) 48 | { 49 | while (!stoppingToken.IsCancellationRequested) 50 | { 51 | var delay = _options.Value.ProcessorMaxDelay; 52 | try 53 | { 54 | var processedScheduledMessages = await ProcessBatch(stoppingToken); 55 | 56 | if (processedScheduledMessages >= _options.Value.ProcessorBatchSize) 57 | { 58 | delay = _options.Value.ProcessorMinDelay; 59 | } 60 | } 61 | catch (Exception ex) 62 | { 63 | _logger.LogError(ex, "Error scheduling next message"); 64 | } 65 | 66 | if (delay >= 10) 67 | { 68 | await Task.Delay(delay, stoppingToken); 69 | } 70 | } 71 | } 72 | 73 | /// 74 | /// Processes a batch of scheduled messages. 75 | /// 76 | /// The cancellation token. 77 | /// The number of processed scheduled messages. 78 | internal async Task ProcessBatch(CancellationToken cancellationToken = default) 79 | { 80 | using var scope = _scopeFactory.CreateScope(); 81 | var dbContext = scope.ServiceProvider.GetRequiredService(); 82 | var producer = scope.ServiceProvider.GetRequiredService(); 83 | var currentTime = _timeProvider.GetUtcNow().ToUnixTimeSeconds(); 84 | var processedScheduledMessageCount = 0; 85 | await using var dbContextTransaction = await dbContext.Database.BeginTransactionAsync(cancellationToken); 86 | try 87 | { 88 | var set = dbContext.Set(); 89 | 90 | var messages = await _messageFetcher.Fetch(set, currentTime, cancellationToken); 91 | 92 | if (messages.Count == 0) 93 | // No messages waiting. 94 | { 95 | return 0; 96 | } 97 | 98 | using var activity = 99 | AsyncMonolithInstrumentation.ActivitySource.StartActivity(AsyncMonolithInstrumentation 100 | .ProcessScheduledMessageActivity); 101 | activity?.AddTag("scheduled_message.count", messages.Count); 102 | 103 | foreach (var message in messages) 104 | { 105 | producer.Produce(message); 106 | message.AvailableAfter = message.GetNextOccurrence(_timeProvider); 107 | processedScheduledMessageCount++; 108 | } 109 | 110 | await dbContext.SaveChangesAsync(cancellationToken); 111 | await dbContextTransaction.CommitAsync(cancellationToken); 112 | activity?.SetStatus(ActivityStatusCode.Ok); 113 | _logger.LogInformation("Successfully scheduled message"); 114 | } 115 | catch (Exception) 116 | { 117 | await dbContextTransaction.RollbackAsync(cancellationToken); 118 | throw; 119 | } 120 | 121 | return processedScheduledMessageCount; 122 | } 123 | } -------------------------------------------------------------------------------- /AsyncMonolith/Scheduling/ScheduledMessageProcessorFactory.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore; 2 | using Microsoft.Extensions.DependencyInjection; 3 | using Microsoft.Extensions.Hosting; 4 | 5 | namespace AsyncMonolith.Scheduling; 6 | 7 | /// 8 | /// Represents a factory for creating and managing multiple instances of as 9 | /// hosted services. 10 | /// 11 | /// The type of the used by the . 12 | public class ScheduledMessageProcessorFactory : IHostedService where T : DbContext 13 | { 14 | private readonly List _hostedServices; 15 | private readonly int _instances; 16 | private readonly IServiceProvider _serviceProvider; 17 | 18 | /// 19 | /// Initializes a new instance of the class. 20 | /// 21 | /// The used to resolve dependencies. 22 | /// The number of instances of to create and manage. 23 | public ScheduledMessageProcessorFactory(IServiceProvider serviceProvider, int instances) 24 | { 25 | _serviceProvider = serviceProvider; 26 | _instances = instances; 27 | _hostedServices = new List(); 28 | } 29 | 30 | /// 31 | /// Starts all the instances of as hosted services. 32 | /// 33 | /// The cancellation token to stop the operation. 34 | public async Task StartAsync(CancellationToken cancellationToken) 35 | { 36 | for (var i = 0; i < _instances; i++) 37 | { 38 | var hostedService = ActivatorUtilities.CreateInstance>(_serviceProvider); 39 | _hostedServices.Add(hostedService); 40 | await hostedService.StartAsync(cancellationToken); 41 | } 42 | } 43 | 44 | /// 45 | /// Stops all the instances of as hosted services. 46 | /// 47 | /// The cancellation token to stop the operation. 48 | public async Task StopAsync(CancellationToken cancellationToken) 49 | { 50 | foreach (var hostedService in _hostedServices) 51 | { 52 | await hostedService.StopAsync(cancellationToken); 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /AsyncMonolith/Utilities/AsyncMonolithIdGenerator.cs: -------------------------------------------------------------------------------- 1 | using System.Security.Cryptography; 2 | 3 | namespace AsyncMonolith.Utilities; 4 | 5 | /// 6 | /// Represents an asynchronous monolith ID generator. 7 | /// 8 | public interface IAsyncMonolithIdGenerator 9 | { 10 | /// 11 | /// Generates a new ID. 12 | /// 13 | /// The generated ID. 14 | string GenerateId(); 15 | } 16 | 17 | /// 18 | /// Represents an implementation of the interface. 19 | /// 20 | public sealed class AsyncMonolithIdGenerator : IAsyncMonolithIdGenerator 21 | { 22 | private const string ValidIdCharacters = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; 23 | private const int Length = 12; 24 | 25 | private static readonly char[] Characters = ValidIdCharacters.ToCharArray(); 26 | private static readonly int CharacterSetLength = Characters.Length; 27 | private static readonly RandomNumberGenerator Rng = RandomNumberGenerator.Create(); 28 | 29 | /// 30 | public string GenerateId() 31 | { 32 | var result = new char[Length]; 33 | var buffer = new byte[Length * 4]; // Allocate buffer for 4 bytes per character 34 | 35 | Rng.GetBytes(buffer); // Fill buffer with cryptographically secure random bytes 36 | 37 | for (var i = 0; i < Length; i++) 38 | { 39 | result[i] = Characters[BitConverter.ToUInt32(buffer, i * 4) % CharacterSetLength]; 40 | } 41 | 42 | return new string(result); 43 | } 44 | } -------------------------------------------------------------------------------- /AsyncMonolith/Utilities/AsyncMonolithInstrumentation.cs: -------------------------------------------------------------------------------- 1 | using System.Diagnostics; 2 | using System.Text.Json; 3 | 4 | namespace AsyncMonolith.Utilities; 5 | 6 | /// 7 | /// Provides instrumentation for the AsyncMonolith application. 8 | /// 9 | public static class AsyncMonolithInstrumentation 10 | { 11 | /// 12 | /// The name of the activity source. 13 | /// 14 | public const string ActivitySourceName = "async_monolith"; 15 | 16 | /// 17 | /// The activity name for processing consumer messages. 18 | /// 19 | public const string ProcessConsumerMessageActivity = "process_consumer_message"; 20 | 21 | /// 22 | /// The activity name for processing scheduled messages. 23 | /// 24 | public const string ProcessScheduledMessageActivity = "process_scheduled_message"; 25 | 26 | /// 27 | /// The activity source instance. 28 | /// 29 | public static readonly ActivitySource ActivitySource = new(ActivitySourceName); 30 | } 31 | -------------------------------------------------------------------------------- /AsyncMonolith/Utilities/AsyncMonolithSettings.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | 3 | namespace AsyncMonolith.Utilities; 4 | 5 | /// 6 | /// Represents the settings for the AsyncMonolith application. 7 | /// 8 | public class AsyncMonolithSettings 9 | { 10 | internal readonly HashSet AssembliesToRegister = []; 11 | 12 | /// 13 | /// Gets or sets the maximum number of attempts for processing a message. 14 | /// Default: 5, Min: 1, Max N/A 15 | /// 16 | public int MaxAttempts { get; set; } = 5; 17 | 18 | /// 19 | /// Gets or sets the delay in seconds between attempts for processing a failed message. 20 | /// Default: 10 seconds, Min: 0 second, Max N/A 21 | /// 22 | public int AttemptDelay { get; set; } = 10; 23 | 24 | /// 25 | /// Gets or sets the maximum delay in milliseconds between processor cycles. 26 | /// Default: 1000ms, Min: 1ms, Max N/A 27 | /// 28 | public int ProcessorMaxDelay { get; set; } = 1000; 29 | 30 | /// 31 | /// Gets or sets the minimum delay in milliseconds between processor cycles. 32 | /// Default: 10ms, Min: 0ms, Max N/A 33 | /// 34 | public int ProcessorMinDelay { get; set; } = 10; 35 | 36 | /// 37 | /// Gets or sets the number of messages to process in a batch.# 38 | /// Default: 5, Min: 1, Max N/A 39 | /// 40 | public int ProcessorBatchSize { get; set; } = 5; 41 | 42 | /// 43 | /// Gets or sets the number of consumer message processors to be ran for each app instance. 44 | /// Default: 1, Min: 1, Max N/A 45 | /// 46 | public int ConsumerMessageProcessorCount { get; set; } = 1; 47 | 48 | /// 49 | /// Gets or sets the number of scheduled message processors to be ran for each app instance. 50 | /// Default: 1, Min: 1, Max N/A 51 | /// 52 | public int ScheduledMessageProcessorCount { get; set; } = 1; 53 | 54 | /// 55 | /// Gets or sets the default number of seconds a consumer waits before timing out. 56 | /// Default: 10 seconds, Min: 1 second, Max 3600 seconds 57 | /// 58 | public int DefaultConsumerTimeout { get; set; } = 10; 59 | 60 | /// 61 | /// Gets the default AsyncMonolithSettings. 62 | /// 63 | public static AsyncMonolithSettings Default => new() 64 | { 65 | MaxAttempts = 5, 66 | AttemptDelay = 10, 67 | ProcessorMaxDelay = 1000, 68 | ProcessorMinDelay = 10, 69 | ConsumerMessageProcessorCount = 1, 70 | ScheduledMessageProcessorCount = 1, 71 | ProcessorBatchSize = 5 72 | }; 73 | 74 | /// 75 | /// Register consumers and payloads from assembly containing given type. 76 | /// 77 | /// Type from assembly to scan. 78 | /// The current instance to continue configuration. 79 | public AsyncMonolithSettings RegisterTypesFromAssemblyContaining() 80 | => RegisterTypesFromAssemblyContaining(typeof(T)); 81 | 82 | /// 83 | /// Register consumers and payloads from assembly containing given type. 84 | /// 85 | /// Type from assembly to scan. 86 | /// The current instance to continue configuration. 87 | public AsyncMonolithSettings RegisterTypesFromAssemblyContaining(Type type) 88 | => RegisterTypesFromAssembly(type.Assembly); 89 | 90 | /// 91 | /// Register consumers and payloads from assembly. 92 | /// 93 | /// Assembly to scan 94 | /// The current instance to continue configuration. 95 | public AsyncMonolithSettings RegisterTypesFromAssembly(Assembly assembly) 96 | => RegisterTypesFromAssemblies([assembly]); 97 | 98 | /// 99 | /// Register consumers and payloads from assemblies. 100 | /// 101 | /// Assemblies to scan. 102 | /// The current instance to continue configuration. 103 | public AsyncMonolithSettings RegisterTypesFromAssemblies(params Assembly[] assemblies) 104 | { 105 | foreach (var assembly in assemblies) 106 | { 107 | AssembliesToRegister.Add(assembly); 108 | } 109 | 110 | return this; 111 | } 112 | } -------------------------------------------------------------------------------- /AsyncMonolith/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Timmoth/AsyncMonolith/9a1a8df00ae1c5d736d37d4026bdc70d75eb0360/AsyncMonolith/logo.png -------------------------------------------------------------------------------- /Demo/ApplicationDbContext.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using AsyncMonolith.Scheduling; 3 | using AsyncMonolith.Utilities; 4 | using Demo.Spam; 5 | using Microsoft.EntityFrameworkCore; 6 | 7 | namespace Demo; 8 | 9 | public class ApplicationDbContext : DbContext 10 | { 11 | public ApplicationDbContext(DbContextOptions options) : base(options) 12 | { 13 | } 14 | 15 | public DbSet SubmittedValues { get; set; } = default!; 16 | public DbSet ConsumerMessages { get; set; } = default!; 17 | public DbSet PoisonedMessages { get; set; } = default!; 18 | public DbSet ScheduledMessages { get; set; } = default!; 19 | 20 | protected override void OnModelCreating(ModelBuilder modelBuilder) 21 | { 22 | modelBuilder.ConfigureAsyncMonolith(); 23 | base.OnModelCreating(modelBuilder); 24 | } 25 | } -------------------------------------------------------------------------------- /Demo/Counter/TotalValueConsumer.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using Microsoft.EntityFrameworkCore; 3 | 4 | namespace Demo.Counter; 5 | 6 | public class TotalValueConsumer : BaseConsumer 7 | { 8 | private readonly ApplicationDbContext _dbContext; 9 | private readonly TotalValueService _totalValueService; 10 | 11 | public TotalValueConsumer(TotalValueService totalValueService, ApplicationDbContext dbContext) 12 | { 13 | _totalValueService = totalValueService; 14 | _dbContext = dbContext; 15 | } 16 | 17 | public override async Task Consume(ValuePersisted message, CancellationToken cancellationToken) 18 | { 19 | var totalValue = await _dbContext.SubmittedValues.SumAsync(v => v.Value, cancellationToken); 20 | _totalValueService.Set(totalValue); 21 | await _dbContext.SaveChangesAsync(cancellationToken); 22 | } 23 | } -------------------------------------------------------------------------------- /Demo/Counter/TotalValueService.cs: -------------------------------------------------------------------------------- 1 | namespace Demo.Counter; 2 | 3 | public class TotalValueService 4 | { 5 | private static double TotalValue { get; set; } 6 | 7 | public void Set(double totalValue) 8 | { 9 | TotalValue = totalValue; 10 | } 11 | 12 | public double Get() 13 | { 14 | return TotalValue; 15 | } 16 | } -------------------------------------------------------------------------------- /Demo/Counter/ValueController.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Producers; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace Demo.Counter; 5 | 6 | [ApiController] 7 | [Route("api/values")] 8 | public class ValueController : ControllerBase 9 | { 10 | private readonly ApplicationDbContext _dbContext; 11 | private readonly IProducerService _producerService; 12 | private readonly TotalValueService _totalValueService; 13 | 14 | public ValueController(IProducerService producerService, ApplicationDbContext dbContext, 15 | TotalValueService totalValueService) 16 | { 17 | _producerService = producerService; 18 | _dbContext = dbContext; 19 | _totalValueService = totalValueService; 20 | } 21 | 22 | [HttpGet] 23 | public async Task Get(CancellationToken cancellationToken) 24 | { 25 | var newValue = 1; 26 | var sum = _totalValueService.Get(); 27 | 28 | await _producerService.Produce(new ValueSubmitted 29 | { 30 | Value = newValue 31 | }); 32 | await _dbContext.SaveChangesAsync(cancellationToken); 33 | 34 | return Ok(sum + newValue); 35 | } 36 | } -------------------------------------------------------------------------------- /Demo/Counter/ValuePersisted.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | 3 | namespace Demo.Counter; 4 | 5 | public class ValuePersisted : IConsumerPayload 6 | { 7 | } -------------------------------------------------------------------------------- /Demo/Counter/ValueSubmitted.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using AsyncMonolith.Consumers; 3 | 4 | namespace Demo.Counter; 5 | 6 | public class ValueSubmitted : IConsumerPayload 7 | { 8 | [JsonPropertyName("value")] public required double Value { get; set; } 9 | } -------------------------------------------------------------------------------- /Demo/Counter/ValueSubmittedConsumer.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | using AsyncMonolith.Producers; 3 | using Demo.Spam; 4 | 5 | namespace Demo.Counter; 6 | 7 | public class ValueSubmittedConsumer : BaseConsumer 8 | { 9 | private readonly ApplicationDbContext _dbContext; 10 | private readonly IProducerService _producerService; 11 | 12 | public ValueSubmittedConsumer(ApplicationDbContext dbContext, IProducerService producerService) 13 | { 14 | _dbContext = dbContext; 15 | _producerService = producerService; 16 | } 17 | 18 | public override async Task Consume(ValueSubmitted message, CancellationToken cancellationToken = default) 19 | { 20 | var newValue = new SubmittedValue 21 | { 22 | Value = message.Value 23 | }; 24 | 25 | _dbContext.SubmittedValues.Add(newValue); 26 | await _producerService.Produce(new ValuePersisted(), cancellationToken: cancellationToken); 27 | await _dbContext.SaveChangesAsync(cancellationToken); 28 | } 29 | } -------------------------------------------------------------------------------- /Demo/Demo.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | net8.0 5 | enable 6 | enable 7 | preview 8 | c991b6df-a810-4f5d-aa76-760a5c9281e9 9 | Linux 10 | ..\docker-compose.dcproj 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | -------------------------------------------------------------------------------- /Demo/Demo.http: -------------------------------------------------------------------------------- 1 | @Demo_HostAddress = http://localhost:5210 2 | 3 | GET {{Demo_HostAddress}}/weatherforecast/ 4 | Accept: application/json 5 | 6 | ### 7 | -------------------------------------------------------------------------------- /Demo/Dockerfile: -------------------------------------------------------------------------------- 1 | #See https://aka.ms/customizecontainer to learn how to customize your debug container and how Visual Studio uses this Dockerfile to build your images for faster debugging. 2 | 3 | FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base 4 | USER app 5 | WORKDIR /app 6 | EXPOSE 8080 7 | EXPOSE 8081 8 | 9 | FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build 10 | ARG BUILD_CONFIGURATION=Release 11 | WORKDIR /src 12 | COPY ["Demo/Demo.csproj", "Demo/"] 13 | COPY ["AsnyMonolith/AsnyMonolith.csproj", "AsnyMonolith/"] 14 | RUN dotnet restore "./Demo/Demo.csproj" 15 | COPY . . 16 | WORKDIR "/src/Demo" 17 | RUN dotnet build "./Demo.csproj" -c $BUILD_CONFIGURATION -o /app/build 18 | 19 | FROM build AS publish 20 | ARG BUILD_CONFIGURATION=Release 21 | RUN dotnet publish "./Demo.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false 22 | 23 | FROM base AS final 24 | WORKDIR /app 25 | COPY --from=publish /app/publish . 26 | ENTRYPOINT ["dotnet", "Demo.dll"] -------------------------------------------------------------------------------- /Demo/Migrations/20240615074531_InitialMigration.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; 3 | 4 | #nullable disable 5 | 6 | namespace Demo.Migrations 7 | { 8 | /// 9 | public partial class InitialMigration : Migration 10 | { 11 | /// 12 | protected override void Up(MigrationBuilder migrationBuilder) 13 | { 14 | migrationBuilder.CreateTable( 15 | name: "consumer_messages", 16 | columns: table => new 17 | { 18 | id = table.Column(type: "text", nullable: false), 19 | created_at = table.Column(type: "bigint", nullable: false), 20 | available_after = table.Column(type: "bigint", nullable: false), 21 | attempts = table.Column(type: "integer", nullable: false), 22 | consumer_type = table.Column(type: "text", nullable: false), 23 | payload_type = table.Column(type: "text", nullable: false), 24 | payload = table.Column(type: "text", nullable: false), 25 | insert_id = table.Column(type: "text", nullable: false) 26 | }, 27 | constraints: table => 28 | { 29 | table.PrimaryKey("PK_consumer_messages", x => x.id); 30 | }); 31 | 32 | migrationBuilder.CreateTable( 33 | name: "poisoned_messages", 34 | columns: table => new 35 | { 36 | id = table.Column(type: "text", nullable: false), 37 | created_at = table.Column(type: "bigint", nullable: false), 38 | available_after = table.Column(type: "bigint", nullable: false), 39 | attempts = table.Column(type: "integer", nullable: false), 40 | consumer_type = table.Column(type: "text", nullable: false), 41 | payload_type = table.Column(type: "text", nullable: false), 42 | payload = table.Column(type: "text", nullable: false), 43 | insert_id = table.Column(type: "text", nullable: false) 44 | }, 45 | constraints: table => 46 | { 47 | table.PrimaryKey("PK_poisoned_messages", x => x.id); 48 | }); 49 | 50 | migrationBuilder.CreateTable( 51 | name: "scheduled_messages", 52 | columns: table => new 53 | { 54 | id = table.Column(type: "text", nullable: false), 55 | tag = table.Column(type: "text", nullable: true), 56 | available_after = table.Column(type: "bigint", nullable: false), 57 | chron_expression = table.Column(type: "text", nullable: false), 58 | chron_timezone = table.Column(type: "text", nullable: false), 59 | payload_type = table.Column(type: "text", nullable: false), 60 | payload = table.Column(type: "text", nullable: false) 61 | }, 62 | constraints: table => 63 | { 64 | table.PrimaryKey("PK_scheduled_messages", x => x.id); 65 | }); 66 | 67 | migrationBuilder.CreateTable( 68 | name: "submitted_values", 69 | columns: table => new 70 | { 71 | Id = table.Column(type: "integer", nullable: false) 72 | .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), 73 | value = table.Column(type: "double precision", nullable: false) 74 | }, 75 | constraints: table => 76 | { 77 | table.PrimaryKey("PK_submitted_values", x => x.Id); 78 | }); 79 | 80 | migrationBuilder.CreateIndex( 81 | name: "IX_consumer_messages_insert_id_consumer_type", 82 | table: "consumer_messages", 83 | columns: new[] { "insert_id", "consumer_type" }, 84 | unique: true); 85 | } 86 | 87 | /// 88 | protected override void Down(MigrationBuilder migrationBuilder) 89 | { 90 | migrationBuilder.DropTable( 91 | name: "consumer_messages"); 92 | 93 | migrationBuilder.DropTable( 94 | name: "poisoned_messages"); 95 | 96 | migrationBuilder.DropTable( 97 | name: "scheduled_messages"); 98 | 99 | migrationBuilder.DropTable( 100 | name: "submitted_values"); 101 | } 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /Demo/Migrations/20240618182258_TraceId.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.EntityFrameworkCore.Migrations; 2 | 3 | #nullable disable 4 | 5 | namespace Demo.Migrations 6 | { 7 | /// 8 | public partial class TraceId : Migration 9 | { 10 | /// 11 | protected override void Up(MigrationBuilder migrationBuilder) 12 | { 13 | migrationBuilder.AddColumn( 14 | name: "span_id", 15 | table: "poisoned_messages", 16 | type: "text", 17 | nullable: true); 18 | 19 | migrationBuilder.AddColumn( 20 | name: "trace_id", 21 | table: "poisoned_messages", 22 | type: "text", 23 | nullable: true); 24 | 25 | migrationBuilder.AddColumn( 26 | name: "span_id", 27 | table: "consumer_messages", 28 | type: "text", 29 | nullable: true); 30 | 31 | migrationBuilder.AddColumn( 32 | name: "trace_id", 33 | table: "consumer_messages", 34 | type: "text", 35 | nullable: true); 36 | } 37 | 38 | /// 39 | protected override void Down(MigrationBuilder migrationBuilder) 40 | { 41 | migrationBuilder.DropColumn( 42 | name: "span_id", 43 | table: "poisoned_messages"); 44 | 45 | migrationBuilder.DropColumn( 46 | name: "trace_id", 47 | table: "poisoned_messages"); 48 | 49 | migrationBuilder.DropColumn( 50 | name: "span_id", 51 | table: "consumer_messages"); 52 | 53 | migrationBuilder.DropColumn( 54 | name: "trace_id", 55 | table: "consumer_messages"); 56 | } 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /Demo/Program.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using AsyncMonolith.PostgreSql; 3 | using AsyncMonolith.Scheduling; 4 | using AsyncMonolith.Utilities; 5 | using Demo.Counter; 6 | using Microsoft.EntityFrameworkCore; 7 | using OpenTelemetry.Resources; 8 | using OpenTelemetry.Trace; 9 | 10 | namespace Demo; 11 | 12 | public class Program 13 | { 14 | public static void Main(string[] args) 15 | { 16 | var builder = WebApplication.CreateBuilder(args); 17 | 18 | builder.Services.AddDbContext((sp, options) => 19 | { 20 | options.UseNpgsql( 21 | "Host=async_monolith_demo_postgres;Port=5432;Username=postgres;Password=mypassword;Database=application", 22 | o => { }); 23 | } 24 | ); 25 | 26 | builder.Services.AddOpenTelemetry() 27 | .WithTracing(x => 28 | { 29 | if (builder.Environment.IsDevelopment()) 30 | { 31 | x.SetSampler(); 32 | } 33 | 34 | x.AddSource(AsyncMonolithInstrumentation.ActivitySourceName); 35 | x.AddConsoleExporter(); 36 | }) 37 | .ConfigureResource(c => c.AddService("async_monolith.demo").Build()); 38 | 39 | 40 | builder.Services.AddSingleton(TimeProvider.System); 41 | builder.Services.AddPostgreSqlAsyncMonolith(settings => 42 | { 43 | settings.RegisterTypesFromAssembly(Assembly.GetExecutingAssembly()); 44 | settings.AttemptDelay = 10; 45 | settings.MaxAttempts = 5; 46 | settings.ProcessorMinDelay = 10; 47 | settings.ProcessorMaxDelay = 100; 48 | settings.ConsumerMessageProcessorCount = 1; 49 | settings.ScheduledMessageProcessorCount = 1; 50 | settings.ProcessorBatchSize = 10; 51 | }); 52 | 53 | builder.Services.AddControllers(); 54 | builder.Services.AddScoped(); 55 | 56 | var app = builder.Build(); 57 | 58 | app.UseHttpsRedirection(); 59 | 60 | app.UseAuthorization(); 61 | 62 | app.MapControllers(); 63 | 64 | using (var scope = app.Services.CreateScope()) 65 | { 66 | var dbContext = scope.ServiceProvider.GetRequiredService(); 67 | dbContext.Database.EnsureDeleted(); 68 | dbContext.Database.EnsureCreated(); 69 | 70 | var scheduledMessageService = 71 | scope.ServiceProvider.GetRequiredService(); 72 | 73 | scheduledMessageService.Schedule(new ValueSubmitted 74 | { 75 | Value = 1 76 | }, "*/5 * * * * *", "UTC"); 77 | dbContext.SaveChanges(); 78 | } 79 | 80 | app.Run(); 81 | } 82 | } -------------------------------------------------------------------------------- /Demo/Properties/launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "http": { 4 | "commandName": "Project", 5 | "launchBrowser": true, 6 | "launchUrl": "api/values", 7 | "environmentVariables": { 8 | "ASPNETCORE_ENVIRONMENT": "Development" 9 | }, 10 | "dotnetRunMessages": true, 11 | "applicationUrl": "http://localhost:5210" 12 | }, 13 | "https": { 14 | "commandName": "Project", 15 | "launchBrowser": true, 16 | "launchUrl": "api/values", 17 | "environmentVariables": { 18 | "ASPNETCORE_ENVIRONMENT": "Development" 19 | }, 20 | "dotnetRunMessages": true, 21 | "applicationUrl": "https://localhost:7143;http://localhost:5210" 22 | }, 23 | "IIS Express": { 24 | "commandName": "IISExpress", 25 | "launchBrowser": true, 26 | "launchUrl": "api/values", 27 | "environmentVariables": { 28 | "ASPNETCORE_ENVIRONMENT": "Development" 29 | } 30 | }, 31 | "Container (Dockerfile)": { 32 | "commandName": "Docker", 33 | "launchBrowser": true, 34 | "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}/api/values", 35 | "environmentVariables": { 36 | "ASPNETCORE_HTTPS_PORTS": "8081", 37 | "ASPNETCORE_HTTP_PORTS": "8080" 38 | }, 39 | "publishAllPorts": true, 40 | "useSSL": true 41 | } 42 | }, 43 | "$schema": "http://json.schemastore.org/launchsettings.json", 44 | "iisSettings": { 45 | "windowsAuthentication": false, 46 | "anonymousAuthentication": true, 47 | "iisExpress": { 48 | "applicationUrl": "http://localhost:7946", 49 | "sslPort": 44314 50 | } 51 | } 52 | } -------------------------------------------------------------------------------- /Demo/Spam/SpamController.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Producers; 2 | using Microsoft.AspNetCore.Mvc; 3 | 4 | namespace Demo.Spam; 5 | 6 | [ApiController] 7 | [Route("api/spam")] 8 | public class SpamController : ControllerBase 9 | { 10 | private readonly IProducerService _producerService; 11 | private readonly TimeProvider _timeProvider; 12 | 13 | public SpamController(IProducerService producerService, TimeProvider timeProvider) 14 | { 15 | _producerService = producerService; 16 | _timeProvider = timeProvider; 17 | } 18 | 19 | [HttpGet] 20 | public async Task Spam([FromQuery(Name = "count")] int count, CancellationToken cancellationToken) 21 | { 22 | if (count <= 0) 23 | { 24 | return BadRequest("'count' query parameter must be at least 1"); 25 | } 26 | 27 | if (SpamResultService.Start != null && SpamResultService.End == null) 28 | { 29 | var duration = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds() - SpamResultService.Start; 30 | return Ok( 31 | $"Running. consumed: {SpamResultService.Count} / {count}. {duration / (SpamResultService.Count + 1)}ms per message"); 32 | } 33 | 34 | if (SpamResultService.Start != null && SpamResultService.End != null) 35 | { 36 | var duration = SpamResultService.End - SpamResultService.Start; 37 | SpamResultService.Start = null; 38 | SpamResultService.End = null; 39 | return Ok( 40 | $"Finished consumed: {SpamResultService.Count} / {count}. {duration / (SpamResultService.Count + 1)}ms per message"); 41 | } 42 | 43 | SpamResultService.Count = 0; 44 | 45 | var messages = new List(); 46 | for (var i = 0; i < count - 1; i++) 47 | { 48 | messages.Add(new SpamMessage 49 | { 50 | Last = false 51 | }); 52 | } 53 | 54 | await _producerService.ProduceList(messages); 55 | SpamResultService.Start = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(); 56 | 57 | await _producerService.Produce(new SpamMessage 58 | { 59 | Last = true 60 | }, 10); 61 | 62 | return Ok("Started."); 63 | } 64 | } -------------------------------------------------------------------------------- /Demo/Spam/SpamMessage.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | 3 | namespace Demo.Spam; 4 | 5 | public class SpamMessage : IConsumerPayload 6 | { 7 | public bool Last { get; set; } 8 | } -------------------------------------------------------------------------------- /Demo/Spam/SpamMessageConsumer.cs: -------------------------------------------------------------------------------- 1 | using AsyncMonolith.Consumers; 2 | 3 | namespace Demo.Spam; 4 | 5 | public class SpamMessageConsumer : BaseConsumer 6 | { 7 | private readonly TimeProvider _timeProvider; 8 | 9 | public SpamMessageConsumer(TimeProvider timeProvider) 10 | { 11 | _timeProvider = timeProvider; 12 | } 13 | 14 | public override Task Consume(SpamMessage message, CancellationToken cancellationToken) 15 | { 16 | if (message.Last) 17 | { 18 | SpamResultService.End = _timeProvider.GetUtcNow().ToUnixTimeMilliseconds(); 19 | } 20 | 21 | SpamResultService.Count++; 22 | return Task.CompletedTask; 23 | } 24 | } -------------------------------------------------------------------------------- /Demo/Spam/SpamResultService.cs: -------------------------------------------------------------------------------- 1 | namespace Demo.Spam; 2 | 3 | public static class SpamResultService 4 | { 5 | public static long? Start { get; set; } 6 | public static long? End { get; set; } 7 | public static int Count { get; set; } 8 | } -------------------------------------------------------------------------------- /Demo/Spam/SubmittedValue.cs: -------------------------------------------------------------------------------- 1 | using System.ComponentModel.DataAnnotations; 2 | using System.ComponentModel.DataAnnotations.Schema; 3 | 4 | namespace Demo.Spam; 5 | 6 | [Table("submitted_values")] 7 | public sealed class SubmittedValue 8 | { 9 | [Key] 10 | [DatabaseGenerated(DatabaseGeneratedOption.Identity)] 11 | public int Id { get; set; } 12 | 13 | [Column("value")] public required double Value { get; set; } 14 | } -------------------------------------------------------------------------------- /Demo/appsettings.Development.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning" 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /Demo/appsettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "Logging": { 3 | "LogLevel": { 4 | "Default": "Information", 5 | "Microsoft.AspNetCore": "Warning", 6 | "Microsoft.EntityFrameworkCore.Database.Command": "Warning" 7 | } 8 | }, 9 | "AllowedHosts": "*" 10 | } 11 | -------------------------------------------------------------------------------- /Schemas/asyncmonolith_mariadb.sql: -------------------------------------------------------------------------------- 1 | START TRANSACTION; 2 | 3 | CREATE TABLE consumer_messages ( 4 | id VARCHAR(255) NOT NULL, 5 | created_at BIGINT NOT NULL, 6 | available_after BIGINT NOT NULL, 7 | attempts INT NOT NULL, 8 | consumer_type VARCHAR(255) NOT NULL, 9 | payload_type VARCHAR(255) NOT NULL, 10 | payload TEXT NOT NULL, 11 | insert_id VARCHAR(255) NOT NULL, 12 | trace_id VARCHAR(255) NOT NULL, 13 | span_id VARCHAR(255) NOT NULL, 14 | PRIMARY KEY (id) 15 | ); 16 | 17 | CREATE TABLE poisoned_messages ( 18 | id VARCHAR(255) NOT NULL, 19 | created_at BIGINT NOT NULL, 20 | available_after BIGINT NOT NULL, 21 | attempts INT NOT NULL, 22 | consumer_type VARCHAR(255) NOT NULL, 23 | payload_type VARCHAR(255) NOT NULL, 24 | payload TEXT NOT NULL, 25 | insert_id VARCHAR(255) NOT NULL, 26 | trace_id VARCHAR(255) NOT NULL, 27 | span_id VARCHAR(255) NOT NULL, 28 | PRIMARY KEY (id) 29 | ); 30 | 31 | CREATE TABLE scheduled_messages ( 32 | id VARCHAR(255) NOT NULL, 33 | tag VARCHAR(255), 34 | available_after BIGINT NOT NULL, 35 | chron_expression VARCHAR(255) NOT NULL, 36 | chron_timezone VARCHAR(255) NOT NULL, 37 | payload_type VARCHAR(255) NOT NULL, 38 | payload TEXT NOT NULL, 39 | PRIMARY KEY (id) 40 | ); 41 | 42 | CREATE TABLE submitted_values ( 43 | Id INT AUTO_INCREMENT, 44 | value DOUBLE NOT NULL, 45 | PRIMARY KEY (Id) 46 | ); 47 | 48 | CREATE UNIQUE INDEX IX_consumer_messages_insert_id_consumer_type ON consumer_messages (insert_id, consumer_type); 49 | 50 | COMMIT; 51 | -------------------------------------------------------------------------------- /Schemas/asyncmonolith_mssql.sql: -------------------------------------------------------------------------------- 1 | BEGIN TRANSACTION; 2 | 3 | CREATE TABLE consumer_messages ( 4 | id NVARCHAR(255) NOT NULL, 5 | created_at BIGINT NOT NULL, 6 | available_after BIGINT NOT NULL, 7 | attempts INT NOT NULL, 8 | consumer_type NVARCHAR(255) NOT NULL, 9 | payload_type NVARCHAR(255) NOT NULL, 10 | payload NVARCHAR(MAX) NOT NULL, 11 | insert_id NVARCHAR(255) NOT NULL, 12 | trace_id NVARCHAR(255) NOT NULL, 13 | span_id NVARCHAR(255) NOT NULL, 14 | 15 | CONSTRAINT PK_consumer_messages PRIMARY KEY (id) 16 | ); 17 | 18 | CREATE TABLE poisoned_messages ( 19 | id NVARCHAR(255) NOT NULL, 20 | created_at BIGINT NOT NULL, 21 | available_after BIGINT NOT NULL, 22 | attempts INT NOT NULL, 23 | consumer_type NVARCHAR(255) NOT NULL, 24 | payload_type NVARCHAR(255) NOT NULL, 25 | payload NVARCHAR(MAX) NOT NULL, 26 | insert_id NVARCHAR(255) NOT NULL, 27 | trace_id NVARCHAR(255) NOT NULL, 28 | span_id NVARCHAR(255) NOT NULL, 29 | 30 | CONSTRAINT PK_poisoned_messages PRIMARY KEY (id) 31 | ); 32 | 33 | CREATE TABLE scheduled_messages ( 34 | id NVARCHAR(255) NOT NULL, 35 | tag NVARCHAR(255), 36 | available_after BIGINT NOT NULL, 37 | chron_expression NVARCHAR(255) NOT NULL, 38 | chron_timezone NVARCHAR(255) NOT NULL, 39 | payload_type NVARCHAR(255) NOT NULL, 40 | payload NVARCHAR(MAX) NOT NULL, 41 | CONSTRAINT PK_scheduled_messages PRIMARY KEY (id) 42 | ); 43 | 44 | CREATE TABLE submitted_values ( 45 | Id INT IDENTITY(1,1), 46 | value FLOAT NOT NULL, 47 | CONSTRAINT PK_submitted_values PRIMARY KEY (Id) 48 | ); 49 | 50 | CREATE UNIQUE INDEX IX_consumer_messages_insert_id_consumer_type ON consumer_messages (insert_id, consumer_type); 51 | 52 | COMMIT; 53 | -------------------------------------------------------------------------------- /Schemas/asyncmonolith_mysql.sql: -------------------------------------------------------------------------------- 1 | START TRANSACTION; 2 | 3 | CREATE TABLE consumer_messages ( 4 | id VARCHAR(255) NOT NULL, 5 | created_at BIGINT NOT NULL, 6 | available_after BIGINT NOT NULL, 7 | attempts INT NOT NULL, 8 | consumer_type VARCHAR(255) NOT NULL, 9 | payload_type VARCHAR(255) NOT NULL, 10 | payload TEXT NOT NULL, 11 | insert_id VARCHAR(255) NOT NULL, 12 | trace_id VARCHAR(255) NOT NULL, 13 | span_id VARCHAR(255) NOT NULL, 14 | PRIMARY KEY (id) 15 | ); 16 | 17 | CREATE TABLE poisoned_messages ( 18 | id VARCHAR(255) NOT NULL, 19 | created_at BIGINT NOT NULL, 20 | available_after BIGINT NOT NULL, 21 | attempts INT NOT NULL, 22 | consumer_type VARCHAR(255) NOT NULL, 23 | payload_type VARCHAR(255) NOT NULL, 24 | payload TEXT NOT NULL, 25 | insert_id VARCHAR(255) NOT NULL, 26 | trace_id VARCHAR(255) NOT NULL, 27 | span_id VARCHAR(255) NOT NULL, 28 | PRIMARY KEY (id) 29 | ); 30 | 31 | CREATE TABLE scheduled_messages ( 32 | id VARCHAR(255) NOT NULL, 33 | tag VARCHAR(255), 34 | available_after BIGINT NOT NULL, 35 | chron_expression VARCHAR(255) NOT NULL, 36 | chron_timezone VARCHAR(255) NOT NULL, 37 | payload_type VARCHAR(255) NOT NULL, 38 | payload TEXT NOT NULL, 39 | PRIMARY KEY (id) 40 | ); 41 | 42 | CREATE TABLE submitted_values ( 43 | Id INT AUTO_INCREMENT, 44 | value DOUBLE NOT NULL, 45 | PRIMARY KEY (Id) 46 | ); 47 | 48 | CREATE UNIQUE INDEX IX_consumer_messages_insert_id_consumer_type ON consumer_messages (insert_id, consumer_type); 49 | 50 | COMMIT; 51 | -------------------------------------------------------------------------------- /Schemas/asyncmonolith_postgresql.sql: -------------------------------------------------------------------------------- 1 | START TRANSACTION; 2 | 3 | CREATE TABLE consumer_messages ( 4 | id text NOT NULL, 5 | created_at bigint NOT NULL, 6 | available_after bigint NOT NULL, 7 | attempts integer NOT NULL, 8 | consumer_type text NOT NULL, 9 | payload_type text NOT NULL, 10 | payload text NOT NULL, 11 | insert_id text NOT NULL, 12 | trace_id text NOT NULL, 13 | span_id text NOT NULL, 14 | 15 | CONSTRAINT "PK_consumer_messages" PRIMARY KEY (id) 16 | ); 17 | 18 | CREATE TABLE poisoned_messages ( 19 | id text NOT NULL, 20 | created_at bigint NOT NULL, 21 | available_after bigint NOT NULL, 22 | attempts integer NOT NULL, 23 | consumer_type text NOT NULL, 24 | payload_type text NOT NULL, 25 | payload text NOT NULL, 26 | insert_id text NOT NULL, 27 | trace_id text NOT NULL, 28 | span_id text NOT NULL, 29 | 30 | CONSTRAINT "PK_poisoned_messages" PRIMARY KEY (id) 31 | ); 32 | 33 | CREATE TABLE scheduled_messages ( 34 | id text NOT NULL, 35 | tag text, 36 | available_after bigint NOT NULL, 37 | chron_expression text NOT NULL, 38 | chron_timezone text NOT NULL, 39 | payload_type text NOT NULL, 40 | payload text NOT NULL, 41 | CONSTRAINT "PK_scheduled_messages" PRIMARY KEY (id) 42 | ); 43 | 44 | CREATE TABLE submitted_values ( 45 | "Id" integer GENERATED BY DEFAULT AS IDENTITY, 46 | value double precision NOT NULL, 47 | CONSTRAINT "PK_submitted_values" PRIMARY KEY ("Id") 48 | ); 49 | 50 | CREATE UNIQUE INDEX "IX_consumer_messages_insert_id_consumer_type" ON consumer_messages (insert_id, consumer_type); 51 | 52 | COMMIT; 53 | 54 | -------------------------------------------------------------------------------- /default.DotSettings: -------------------------------------------------------------------------------- 1 |  2 | Required 3 | Required 4 | Required 5 | Required -------------------------------------------------------------------------------- /docker-compose.dcproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 2.1 6 | Linux 7 | False 8 | 806becfb-c02c-4502-bb1d-2cc45ad361b7 9 | LaunchBrowser 10 | {Scheme}://localhost:{ServicePort}/api/values 11 | demo 12 | 13 | 14 | 15 | docker-compose.yml 16 | 17 | 18 | 19 | 20 | -------------------------------------------------------------------------------- /docker-compose.override.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | demo: 5 | environment: 6 | - ASPNETCORE_ENVIRONMENT=Development 7 | - ASPNETCORE_HTTP_PORTS=8080 8 | - ASPNETCORE_HTTPS_PORTS=8081 9 | ports: 10 | - "8080" 11 | - "8081" 12 | volumes: 13 | - ${APPDATA}/Microsoft/UserSecrets:/home/app/.microsoft/usersecrets:ro 14 | - ${APPDATA}/ASP.NET/Https:/home/app/.aspnet/https:ro -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.4' 2 | 3 | services: 4 | demo: 5 | image: ${DOCKER_REGISTRY-}demo 6 | build: 7 | context: . 8 | dockerfile: Demo/Dockerfile 9 | 10 | async_monolith_demo_postgres: 11 | image: postgres:latest 12 | container_name: async_monolith_demo_postgres 13 | environment: 14 | POSTGRES_USER: postgres 15 | POSTGRES_PASSWORD: mypassword 16 | POSTGRES_DB: application 17 | ports: 18 | - "5442:5432" 19 | volumes: 20 | - async_monolith_demo_postgres:/var/lib/postgresql/data 21 | 22 | volumes: 23 | async_monolith_demo_postgres: -------------------------------------------------------------------------------- /docs/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Timmoth/AsyncMonolith/9a1a8df00ae1c5d736d37d4026bdc70d75eb0360/docs/assets/logo.png -------------------------------------------------------------------------------- /docs/contributing.md: -------------------------------------------------------------------------------- 1 | ## Contributing 🙏 2 | 3 | Contributions are welcome! Here’s how you can get involved: 4 | 5 | 1. **Fork the repository**: Click the "Fork" button at the top right of this page. 6 | 2. **Clone your fork**: 7 | ```bash 8 | git clone https://github.com/Timmoth/AsyncMonolith.git 9 | ``` 10 | 3. **Create a branch**: Make your changes in a new branch. 11 | ```bash 12 | git checkout -b my-feature-branch 13 | ``` 14 | 4. **Commit your changes**: 15 | ```bash 16 | git commit -m 'Add some feature' 17 | ``` 18 | 5. **Push to the branch**: 19 | ```bash 20 | git push origin my-feature-branch 21 | ``` 22 | 6. **Open a pull request**: Describe your changes and submit your PR. 23 | -------------------------------------------------------------------------------- /docs/demo.md: -------------------------------------------------------------------------------- 1 | - Hit `https://localhost:60046/api/spam?count=1000` to see how performant AsyncMonolith is on your system. With 10 message batches and single processor instance I usually process (trivial) messages at <10ms each. 2 | - The demo is setup to run against a PostgreSql database, make sure you've got docker installed 3 | -------------------------------------------------------------------------------- /docs/guides/changing-messages.md: -------------------------------------------------------------------------------- 1 | - **Backwards Compatibility**: When modifying consumer payload schemas, ensure changes are backwards compatible so that existing messages with the old schema can still be processed. 2 | - **Schema Migration**: 3 | - If changes are not backwards compatible, make the changes in a copy of the `ConsumerPayload` (with a different class name) and update all consumers to operate on the new payload. 4 | - Once all messages with the old payload schema have been processed, you can safely delete the old payload schema and its associated consumers. 5 | -------------------------------------------------------------------------------- /docs/guides/consuming-messages.md: -------------------------------------------------------------------------------- 1 | - **Independent Consumption**: Each message will be consumed independently by each consumer set up to handle it. 2 | - **Periodic Querying**: Each instance of your app will periodically query the `consumer_messages` table for a batch of available messages to process. 3 | - The query takes place at the frequency defined by `ProcessorMaxDelay`, if a full batch is returned it will delay by `ProcessorMinDelay`. 4 | - **Concurrency**: Each app instance can run multiple parallel consumer processors defined by `ConsumerMessageProcessorCount`, unless using `AsyncMonolith.Ef`. 5 | - **Batching**: Consumer messages will be read from the `consumer_messages` table in batches defined by `ConsumerMessageBatchSize`. 6 | - **Idempotency**: Ensure your Consumers are idempotent, since they will be retried on failure. 7 | - **Timeout**: Consumers timeout after the number of seconds defined by the `ConsumerTimeout` attribute or the `DefaultConsumerTimeout` if not set. 8 | 9 | Example 10 | 11 | ```csharp 12 | [ConsumerTimeout(5)] // Consumer timeouts after 5 seconds 13 | [ConsumerAttempts(1)] // Consumer messages moved to poisoned table after 1 failed attempt 14 | public class DeleteUsersPosts : BaseConsumer 15 | { 16 | private readonly ApplicationDbContext _dbContext; 17 | 18 | public DeleteUsersPosts(ApplicationDbContext dbContext) 19 | { 20 | _dbContext = dbContext; 21 | } 22 | 23 | public override Task Consume(UserDeleted message, CancellationToken cancellationToken) 24 | { 25 | ... 26 | await _dbContext.SaveChangesAsync(cancellationToken); 27 | } 28 | } 29 | ``` 30 | 31 | ## Consumer Failures 💢 32 | 33 | - **Retry Logic**: Messages will be retried up to `MaxAttempts` times (with a `AttemptDelay` seconds between attempts) until they are moved to the `poisoned_messages` table. Add the `[ConsumerAttempts(1)]` attribute to override this behavior. 34 | - **Manual Intervention**: If a message is moved to the `poisoned_messages` table, it will need to be manually removed from the database or moved back to the `consumer_messages` table to be retried. Note that the poisoned message will only be retried a single time unless you set `attempts` back to 0. 35 | - **Monitoring**: Periodically monitor the `poisoned_messages` table to ensure there are not too many failed messages. 36 | -------------------------------------------------------------------------------- /docs/guides/opentelemetry.md: -------------------------------------------------------------------------------- 1 | Ensure you add `AsyncMonolithInstrumentation.ActivitySourceName` as a source to your OpenTelemetry configuration if you want to receive consumer / scheduled processor traces. 2 | 3 | ```csharp 4 | builder.Services.AddOpenTelemetry() 5 | .WithTracing(x => 6 | { 7 | if (builder.Environment.IsDevelopment()) x.SetSampler(); 8 | 9 | x.AddSource(AsyncMonolithInstrumentation.ActivitySourceName); 10 | x.AddConsoleExporter(); 11 | }) 12 | .ConfigureResource(c => c.AddService("async_monolith.demo").Build()); 13 | ``` 14 | 15 | | Tag | Description | 16 | |-------------------------------|-----------------------| 17 | | consumer_message.id | Consumer message Id | 18 | | consumer_message.attempt | Attempt number | 19 | | consumer_message.payload.type | Message payload type | 20 | | consumer_message.type | Message consumer type | 21 | | exception.type | Exception type | 22 | | exception.message | Exception message | 23 | -------------------------------------------------------------------------------- /docs/guides/producing-messages.md: -------------------------------------------------------------------------------- 1 | To produce messages in dotnet apps using [AsyncMonolith](https://github.com/timmoth/asyncmonolith) you must first define a consumer payload class. This will act as the body of the message being passed to each consumer configured to handle it. 2 | 3 | ```csharp 4 | 5 | public class OrderCancelled : IConsumerPayload 6 | { 7 | [JsonPropertyName("order_id")] 8 | public string OrderId { get; set; } 9 | 10 | [JsonPropertyName("cancelled_at")] 11 | public DateTimeOffset CancelledAt { get; set; } 12 | } 13 | 14 | ``` 15 | 16 | When defining a consumer payload it must derive from the `IConsumerPayload` interface and be serializable by the `System.Text.Json.JsonSerializer`. 17 | 18 | As the consumer payload will be stored in the database in a serialized string, it is a good practice to keep it as small as possible. 19 | 20 | To produce your message you'll need to inject a `IProducerService` 21 | 22 | ## Immediate messages 23 | You can produce messages to be consumed immediately like this: 24 | ```csharp 25 | 26 | order.Cancel(); 27 | _dbContext.Orders.Update(order); 28 | await _producerService.Produce(new OrderCancelled() 29 | { 30 | OrderId = order.Id, 31 | CancelledAt = _timeProvider.UtcNow() 32 | }); 33 | 34 | // Save changes 35 | await _dbContext.SaveChangesAsync(cancellationToken); 36 | ``` 37 | 38 | The message will be produced transactionally along with the change to your domain objects when you call `SaveChangesAsync`. Lean more about the [Transactional Outbox](../transactional-outbox) pattern. 39 | 40 | ***If using the MySql, MsSql, MariaDb or PostgreSQL packages you will need to wrap your changes in a transaction see below*** 41 | 42 | ## Delayed messages 43 | You can produce messages to be consumed after a delay by specifying the number of seconds to wait before a consumer should process the message. 44 | ```csharp 45 | 46 | order.Cancel(); 47 | _dbContext.Orders.Update(order); 48 | await _producerService.Produce(new OrderCancelled() 49 | { 50 | OrderId = order.Id, 51 | CancelledAt = _timeProvider.UtcNow() 52 | }, 60); 53 | 54 | // Save changes 55 | await _dbContext.SaveChangesAsync(cancellationToken); 56 | ``` 57 | 58 | ## Deduplicated messages 59 | Deduplicated messages are useful when you may emit the same message multiple times but only require it to be processed once for a given time period. For instance you may want to aggregate page views no more frequently then once every 10 seconds, you could schedule a reccuring message for this, but it may be wasteful if you anticipate pages go without views for extended periods of time. 60 | 61 | ```csharp 62 | 63 | pageView.Increment(); 64 | _dbContext.PageViews.Update(pageView); 65 | await _producerService.Produce(new PageViewed() 66 | { 67 | PageId = pageView.Id, 68 | }, 10, $"page_id:{pageView.Id}"); 69 | 70 | // Save changes 71 | await _dbContext.SaveChangesAsync(cancellationToken); 72 | ``` 73 | 74 | Deduplicated events will ensure only a single message for a given consumer type and insertId are ever pending processing at any given time. 75 | 76 | ### MySql / MsSql / MariaDb / PostgreSql Transactionality 77 | 78 | The produce method makes use of `ExecuteSqlRawAsync` when using the MySql, MsSql or PostgreSQL package, if you want the messages to be inserted transactionally with your domain changes you must wrap all the changes in an explicit transaction. 79 | 80 | ```csharp 81 | await using var dbContextTransaction = await _dbContext.Database.BeginTransactionAsync(cancellationToken); 82 | 83 | order.Cancel(); 84 | _dbContext.Orders.Update(order); 85 | await _producerService.Produce(new OrderCancelled() 86 | { 87 | OrderId = order.Id, 88 | CancelledAt = _timeProvider.UtcNow() 89 | }); 90 | 91 | await _dbContext.SaveChangesAsync(cancellationToken); 92 | await dbContextTransaction.CommitAsync(cancellationToken); 93 | 94 | ``` 95 | 96 | Summary 97 | 98 | - **Transactional Persistence**: Produce messages along with changes to your `DbContext` before calling `SaveChangesAsync`, ensuring your domain changes and the messages they produce are persisted transactionally. 99 | - **Delay**: Specify the number of seconds a message should remain in the queue before it is processed by a consumer. 100 | - **Deduplication**: By specifying a `insert_id` when producing messages the system ensures only one message with the same `insert_id` and `consumer_type` will be in the table at a given time. This is useful when you need a process to take place an amount of time after the first action in a sequence occured. 101 | -------------------------------------------------------------------------------- /docs/guides/scheduling-messages.md: -------------------------------------------------------------------------------- 1 | - **Frequency**: Scheduled messages will be produced periodically by the `chron_expression` in the given `chron_timezone` 2 | - **Transactional Persistence**: Schedule messages along with changes to your `DbContext` before calling `SaveChangesAsync`, ensuring your domain changes and the messages they produce are persisted transactionally. 3 | - **Processing**: Schedule messages will be processed sequentially after they are made available by their chron job, at which point they will be turned into Consumer Messages and inserted into the `consumer_messages` table to be handled by their respective consumers. 4 | 5 | To produce your message you'll need to inject a `IScheduleService` 6 | 7 | ```csharp 8 | // Publish 'CacheRefreshScheduled' every Monday at 12pm (UTC) with a tag that can be used to modify / delete related scheduled messages. 9 | _scheduleService.Schedule(new CacheRefreshScheduled 10 | { 11 | Id = id 12 | }, "0 0 12 * * MON", "UTC", "id:{id}"); 13 | await _dbContext.SaveChangesAsync(cancellationToken); 14 | ``` -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # [AsyncMonolith](https://github.com/Timmoth/AsyncMonolith) ![Logo](assets/logo.png) 2 | 3 | [![Ef](https://img.shields.io/nuget/v/AsyncMonolith.Ef?label=Ef)](https://www.nuget.org/packages/AsyncMonolith.Ef) 4 | [![MySql](https://img.shields.io/nuget/v/AsyncMonolith.MySql?label=MySql)](https://www.nuget.org/packages/AsyncMonolith.MySql) 5 | [![MsSql](https://img.shields.io/nuget/v/AsyncMonolith.MsSql?label=MsSql)](https://www.nuget.org/packages/AsyncMonolith.MsSql) 6 | [![PostgreSql](https://img.shields.io/nuget/v/AsyncMonolith.PostgreSql?label=PostgreSql)](https://www.nuget.org/packages/AsyncMonolith.PostgreSql) 7 | [![MariaDb](https://img.shields.io/nuget/v/AsyncMonolith.MariaDb?label=MariaDb)](https://www.nuget.org/packages/AsyncMonolith.MariaDb) 8 | 9 | AsyncMonolith is a lightweight library that facilitates simple, durable and asynchronous messaging in dotnet apps. 10 | 11 | ### Overview: 12 | - Speed up your API by offloading tasks to a background worker. 13 | - Distribute workload amongst multiple app instances. 14 | - Execute tasks at specific times or after a delay. 15 | - Execute tasks at regular intervals. 16 | - Simplify complex processes by building out an event-driven architecture. 17 | - Decouple your services by utilizing the Mediator pattern. 18 | - Improve your application's resiliency by utilizing the Transactional Outbox pattern. 19 | - Improve your application's resiliency by utilizing automatic retries. 20 | - Keep your infrastructure simple without using a message broker. 21 | - Simplify testability. 22 | 23 | > [!NOTE] 24 | > Despite its name, AsyncMonolith can be used within a microservices architecture. The only requirement is that the producers and consumers share the same database i.e messaging within a single project. However, it is not suitable for passing messages between different projects in a microservices architecture, as microservices should not share the same database. 25 | 26 | !!! note 27 | 28 | Despite its name, AsyncMonolith can be used within a microservices architecture. The only requirement is that the producers and consumers share the same database i.e messaging within a single project. However, it is not suitable for passing messages between different projects in a microservices architecture, as microservices should not share the same database. 29 | 30 | # Support 🛟 31 | 32 | Need help? Ping me on [linkedin](https://www.linkedin.com/in/timmoth/) and I'd be more then happy to jump on a call to debug, help configure or answer any questions. 33 | -------------------------------------------------------------------------------- /docs/internals.md: -------------------------------------------------------------------------------- 1 | ![Logo](assets/internals.svg) 2 | 3 | ## ProducerService 4 | 5 | Resolves consumers for a given payload and writes messages to the `consumer_messages` table for processing. 6 | 7 | ## ScheduleService 8 | 9 | Writes scheduled messages to the `scheduled_messages` table. 10 | 11 | ## DbSet: ConsumerMessage 12 | 13 | Stores all messages awaiting processing by the `ConsumerMessageProcessor`. 14 | 15 | ## DbSet: ScheduledMessage 16 | 17 | Stores all scheduled messages awaiting processing by the `ScheduledMessageProcessor`. 18 | 19 | ## DbSet: PoisonedMessage 20 | 21 | Stores consumer messages that have reached `AsyncMonolith.MaxAttempts`, poisoned messages will then need to be manually moved back to the `consumer_messages` table or deleted. 22 | 23 | ## ConsumerMessageProcessor 24 | 25 | A background service that periodically fetches available messages from the 'consumer_messages' table. Once a message is found, it's row-level locked to prevent other processes from fetching it. The corresponding consumer attempts to process the message. If successful, the message is removed from the `consumer_messages` table; otherwise, the processor increments the messages `attempts` by one and delays processing for a defined number of seconds (`AsyncMonolithSettings.AttemptDelay`). If the number of attempts reaches the limit defined by `AsyncMonolith.MaxAttempts`, the message is moved to the `poisoned_messages` table. 26 | 27 | ## ScheduledMessageProcessor 28 | 29 | A background service that fetches available messages from the `scheduled_messages` table. Once found, each consumer set up to handle the payload is resolved, and a message is written to the `consumer_messages` table for each of them. 30 | 31 | ## ConsumerRegistry 32 | 33 | Used to resolve all the consumers able to process a given payload, and resolve instances of the consumers when processing a message. The registry is populated on startup by calling `builder.Services.AddAsyncMonolith(Assembly.GetExecutingAssembly());` which uses reflection to find all consumer & payload types. 34 | 35 | ## Notes 📋 36 | 37 | - The background services wait for `AsyncMonolithSettings.ProcessorMaxDelay` seconds before fetching another batch of messages. If a full batch is fetched, the delay is reduced to `AsyncMonolithSettings.ProcessorMinDelay` seconds between cycles. 38 | - Configuring concurrent consumer / scheduled message processors will throw a startup exception when using AsyncMonolith.Ef (due to no built in support for row level locking) 39 | -------------------------------------------------------------------------------- /docs/posts/idempotency.md: -------------------------------------------------------------------------------- 1 | Idempotency refers to the ability of an operation to be performed multiple times whilst still yielding the same outcome. It is a critical property of handlers in an event driven system, since events can be reprocessed due to retries, network issues, or other failures. Without idempotency, reprocessing the same event could lead to inconsistent states, duplicate entries, or other unintended side effects. 2 | 3 | Let's consider a simple scenario where an event handler updates the status of an order. 4 | 5 | ```csharp 6 | order.Status = "cancelled"; 7 | await _dbContext.SaveChangesAsync(); 8 | await _emailService.SendCancellationEmail(order.Id); 9 | ``` 10 | 11 | The issue with the above code is that if the event is processed twice the `EmailService` will send a duplicate email. 12 | 13 | We can achieve idempotency easily in this scenario by returning early if the order has already been cancelled. 14 | 15 | ```csharp 16 | if(order.Status == "cancelled"){ 17 | return; 18 | } 19 | order.Status = "cancelled"; 20 | await _dbContext.SaveChangesAsync(); 21 | await _emailService.SendCancellationEmail(order.Id); 22 | ``` 23 | 24 | You may notice an additional issue with the above example. If the `EmailService` throws an exception, the message will be retried but will exit early since the `Status` has already been set to `cancelled`. This problem can be solved by using [AsyncMonolith](https://github.com/Timmoth/AsyncMonolith) to emit an `OrderCancelled` event transactionally along with the changes to your domain, which subsequently invokes a handler that sends an email. Check out the post on the [Transactional Outbox](../transactional-outbox) pattern to learn more. 25 | 26 | The revised version using AsyncMonolith may look like this: 27 | 28 | ```csharp 29 | if(order.Status == "cancelled"){ 30 | return; 31 | } 32 | order.Status = "cancelled"; 33 | await _producerService.Produce(new OrderCancelled() 34 | { 35 | OrderId = order.Id 36 | }); 37 | await _dbContext.SaveChangesAsync(); 38 | ``` 39 | 40 | ```csharp 41 | public class SendOrderCancelledEmail : BaseConsumer 42 | { 43 | public override Task Consume(OrderCancelled message, CancellationToken cancellationToken) 44 | { 45 | ... 46 | // Send order cancelled email 47 | } 48 | } 49 | ``` 50 | 51 | By ensuring that your handlers are idempotent, you can safely reprocess events whilst making sure your system remains consistent and reliable. 52 | -------------------------------------------------------------------------------- /docs/posts/mediator.md: -------------------------------------------------------------------------------- 1 | Let's consider a scenario where you are developing an ordering system. When a user cancels an order, you need to cancel the associated shipment and send the user an email confirming the cancellation. 2 | 3 | ```csharp 4 | order.Cancel(); 5 | _dbContext.Orders.Update(order); 6 | await _dbContext.SaveChangesAsync(); 7 | _shipmentService.CancelShipment(order.ShipmentId); 8 | _emailService.SendCancellationEmail(order.Id); 9 | ``` 10 | 11 | The problem with this approach is that each service is tightly coupled with its dependencies. The `OrderService` must know to tell `ShipmentService` and the `EmailService` that an order has been cancelled. In a simple system, this might not cause significant problems, but as it grows, the number of connections between classes can make them difficult to maintain. 12 | 13 | The Mediator pattern aims to solve this problem by introducing a `Mediator` service which acts as a coordinator between services. Each service sends requests to the Mediator without needing to concern itself with the responsibilities of other services. 14 | 15 | After refactoring the `OrderService`, it now simply updates the order domain object and tells the Mediator that an order has been canceled: 16 | 17 | ```csharp 18 | order.Cancel(); 19 | _dbContext.Orders.Update(order); 20 | await _dbContext.SaveChangesAsync(); 21 | _mediator.Send(new OrderCancelled(order.Id)); 22 | ``` 23 | 24 | It's the Mediator's job to route the request to each handler configured to handle the `OrderCancelled` event within their own context. 25 | 26 | For example the handlers in this scenario could be implemented like this: 27 | 28 | ```csharp 29 | public class CancelShipmentHandler 30 | { 31 | public async Task Handle(OrderCancelled orderCancelled) 32 | { 33 | ... 34 | shipment.Cancel(); 35 | _dbContext.Shipments.Update(shipment); 36 | await _dbContext.SaveChangesAsync(); 37 | } 38 | } 39 | 40 | public class CancellationEmailHandler 41 | { 42 | public async Task Handle(OrderCancelled orderCancelled) 43 | { 44 | ... 45 | // Send order cancelled email 46 | } 47 | } 48 | ``` 49 | 50 | This pattern promotes: 51 | 52 | ### Code reuse 53 | 54 | You may have multiple places where an order can be canceled. Now, you don't have to duplicate the logic to cancel the shipment, send an email, or coordinate those actions when an order is cancelled. 55 | 56 | ### Single responsibility principle 57 | 58 | Each handler is responsible for handling the `OrderCancelled` event within its own context. 59 | 60 | ### Open/closed principle 61 | 62 | Additional handlers can be easily added to extend the behavior of your system without modifying existing code. 63 | 64 | ### Testability 65 | 66 | Handlers can be unit tested in isolation. 67 | 68 | One issue with the mediator pattern is that a part of your system could fail without recourse. For instance, if the `CancelShipmentHandler` fails, your system could be left in an inconsistent state since the order has been canceled, the email has been sent but shipment will still be made. 69 | 70 | [AsyncMonolith](https://github.com/Timmoth/AsyncMonolith) acts as a mediator, providing the benefits of decoupling while ensuring transactional consistency by using the [Transactional Outbox](../transactional-outbox) pattern. This ensures that each message is stored in your database before being handled, so if anything fails, it will be retried multiple times before being moved into a `poisoned_messages` table where you can manually intervene. 71 | 72 | Refactoring the above scenario to use AsyncMonolith may look like this: 73 | 74 | ```csharp 75 | order.Cancel(); 76 | _dbContext.Orders.Update(order); 77 | await _producerService.Produce(new OrderCancelled() 78 | { 79 | OrderId = order.Id 80 | }); 81 | await _dbContext.SaveChangesAsync(); 82 | ``` 83 | 84 | ```csharp 85 | public class CancelShipment : BaseConsumer 86 | { 87 | public override Task Consume(OrderCancelled message, CancellationToken cancellationToken) 88 | { 89 | ... 90 | shipment.Cancel(); 91 | _dbContext.Shipments.Update(shipment); 92 | await _dbContext.SaveChangesAsync(); 93 | } 94 | } 95 | 96 | public class SendOrderCancelledEmail : BaseConsumer 97 | { 98 | public override Task Consume(OrderCancelled message, CancellationToken cancellationToken) 99 | { 100 | ... 101 | // Send order cancelled email 102 | } 103 | } 104 | ``` 105 | -------------------------------------------------------------------------------- /docs/posts/transactional-outbox.md: -------------------------------------------------------------------------------- 1 | Consider a scenario where when a user places an order, you also need to create an entity that tracks the shipment. 2 | 3 | This scenario is fine as the order and shipment are both committed to your database transactionally; either both succeed, or neither do. You will never have an order created without a shipment. 4 | 5 | ```csharp 6 | _dbContext.Orders.Add(newOrder); 7 | _dbContext.Shipments.Add(newShipment); 8 | await _dbContext.SaveChangesAsync(); 9 | ``` 10 | 11 | As your application grows, you find that an increasing number of things need to happen when an order is created, and some of them have their own chain of operations. This will quickly cause any method that needs to create an order to grow in complexity and scope. As a result you decide you want to decouple the process of creating an order from the process of creating a shipment, so you adopt an event-driven approach. 12 | 13 | However, now you have to make a difficult decision: should I commit my order to my database first, then produce an event, or should I produce the event first, then commit the order to my database? 14 | 15 | In the first scenario, it is possible an order is created but publishing the 'OrderCreated' event fails. 16 | 17 | ```csharp 18 | // Create order 19 | _dbContext.Orders.Add(newOrder); 20 | await _dbContext.SaveChangesAsync(); 21 | // Dispatch event 22 | channel.BasicPublish(...); \\ This fails 23 | ``` 24 | 25 | In the second scenario, it is possible that an event is published, but the order fails to be created. 26 | 27 | ```csharp 28 | // Dispatch event 29 | channel.BasicPublish(...); 30 | // Create order 31 | _dbContext.Orders.Add(newOrder); 32 | await _dbContext.SaveChangesAsync(); \\ This fails 33 | ``` 34 | 35 | The transactional outbox is a pattern which solves this problem. The general idea is that instead of publishing an event along with making a change to your database, you create an entity that will publish an event at a later time and commit both changes to the database as part of the same transaction. You then have a process capable of checking for records in the outbox table and publishing them as events while handling failures and retries. 36 | 37 | ```csharp 38 | // Create order 39 | _dbContext.Orders.Add(newOrder); 40 | // Insert event into outbox 41 | _dbContext.Outbox.Add(orderCreated) 42 | await _dbContext.SaveChangesAsync(); 43 | ``` 44 | 45 | For simple use cases, you may realize that the outbox message can act as the event, and you don't actually need the additional step of publishing to a message broker. Instead, you write your events to your database along with the changes to your domain and have a process that periodically fetches, processes, and then removes messages from the outbox table. This is one of the core principles behind [AsyncMonolith](https://github.com/Timmoth/AsyncMonolith). 46 | 47 | In summary, the transactional outbox pattern ensures that events are reliably published, maintaining consistency between operations. While it introduces the need for an additional process to handle the outbox and increases the load on your application database, the benefits of decoupling and reliability often outweigh these overheads. 48 | 49 | Here is how the above example may look if you’re using [AsyncMonolith](https://github.com/Timmoth/AsyncMonolith) 50 | 51 | ```csharp 52 | // Create order 53 | _dbContext.Orders.Add(newOrder); 54 | // Insert event into outbox 55 | await _producerService.Produce(new OrderCreated() 56 | { 57 | OrderId = order.Id 58 | }); 59 | await _dbContext.SaveChangesAsync(); 60 | ``` 61 | 62 | ```csharp 63 | public class CreateShipment : BaseConsumer 64 | { 65 | public override Task Consume(OrderCreated message, CancellationToken cancellationToken) 66 | { 67 | ... 68 | // Create Order 69 | } 70 | } 71 | ``` 72 | -------------------------------------------------------------------------------- /docs/releases.md: -------------------------------------------------------------------------------- 1 | Make sure to check this table before updating the nuget package in your solution, you may be required to add an `dotnet ef migration`. 2 | 3 | | Version | Description | Migration Required | 4 | |---------|--------------------------------------------|--------------------| 5 | | 8.0.6 | Added ConsumerAttempts override attribute | No | 6 | | 8.0.5 | Bundle debug symbols with nuget package | No | 7 | | 8.0.4 | Bundle XML docs with nuget package | No | 8 | | 8.0.3 | Optimised Sql | No | 9 | | 8.0.2 | Added distributed trace_id and span_id | No | 10 | | 8.0.1 | Added OpenTelemetry support | Yes | 11 | | 8.0.0 | Use Producer & Schedule service interfaces | No | 12 | | 1.0.9 | Initial | Yes | 13 | 14 | ***If you're not using ef migrations check out the sql to configure your database [here](https://github.com/Timmoth/AsyncMonolith/tree/main/Schemas)*** 15 | -------------------------------------------------------------------------------- /docs/support.md: -------------------------------------------------------------------------------- 1 | Need help? Ping me on [linkedin](https://www.linkedin.com/in/timmoth/) and I'd be more then happy to jump on a call to debug, help configure or answer any questions. 2 | -------------------------------------------------------------------------------- /docs/tests.md: -------------------------------------------------------------------------------- 1 | ## AsyncMonolith.Tests 2 | 3 | - Some of the test rely on TestContainers to run against real databases, make sure you've got docker installed 4 | 5 | 6 | ## AsyncMonolith.TestHelpers 7 | 8 | [![NuGet](https://img.shields.io/nuget/v/AsyncMonolith.TestHelpers)](https://www.nuget.org/packages/AsyncMonolith.TestHelpers) 9 | 10 | Install the TestHelpers package to help with unit / integration tests. 11 | 12 | ### FakeIdGenerator 13 | 14 | Generates sequential fake id's of the format `$"fake-id-{invocationCount}"` 15 | 16 | ### ConsumerMessageTestHelpers 17 | 18 | Static methods for asserting messages have been inserted into the `consumer_messages` table. 19 | 20 | ### FakeProducerService 21 | 22 | Provides a fake `IProducerService` implementation, which is useful for asserting messages have been published without using a database. 23 | 24 | ### FakeScheduleService 25 | 26 | Provides a fake `IScheduleService` implementation, which is useful for asserting messages have been scheduled without using a database. 27 | 28 | ### SetupTestHelpers 29 | 30 | Includes two methods to configure your `IServiceCollection` without including the background services for processing messages. 31 | 32 | - `AddFakeAsyncMonolithBaseServices` configures fake services which don't depend on a database 33 | - `AddRealAsyncMonolithBaseServices` configures real services without registering the background processors 34 | 35 | ### TestConsumerMessageProcessor 36 | 37 | Static methods for invoking and testing your consumers. 38 | 39 | ### ConsumerTestBase 40 | 41 | The `ConsumerTestBase` offers a way to write simple tests for your consumers 42 | 43 | ```cs 44 | public class CancelShipmentTests : ConsumerTestBase 45 | { 46 | public CancelShipmentTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) 47 | { 48 | } 49 | private readonly string _inMemoryDatabaseName = Guid.NewGuid().ToString(); 50 | protected override Task Setup(IServiceCollection services) 51 | { 52 | services.AddDbContext((sp, options) => 53 | { 54 | options.UseInMemoryDatabase(_inMemoryDatabaseName); 55 | } 56 | ); 57 | 58 | services.AddRealAsyncMonolithBaseServices(settings => 59 | settings.RegisterTypesFromAssemblyContaining()); 60 | return Task.CompletedTask; 61 | } 62 | 63 | [Fact] 64 | public async Task OrderCancelled_Sets_Shipment_Status_To_Cancelled() 65 | { 66 | // Given 67 | var model = new Shipment 68 | { 69 | Id = "test-shipment-id", 70 | OrderId = "test-order-id", 71 | Status = "pending", 72 | }; 73 | 74 | using (var scope = Services.CreateScope()) 75 | { 76 | var dbContext = scope.ServiceProvider.GetRequiredService(); 77 | dbContext.Shipments.Add(model); 78 | await dbContext.SaveChangesAsync(); 79 | } 80 | 81 | // When 82 | await Process(new OrderCancelled() 83 | { 84 | OrderId = model.OrderId 85 | }); 86 | 87 | // Then 88 | using (var scope = Services.CreateScope()) 89 | { 90 | var dbContext = scope.ServiceProvider.GetRequiredService(); 91 | var shipment = await dbContext.Shipments.FirstOrDefaultAsync(c => c.Id == model.Id); 92 | shipment.Status.Should().Be("cancelled"); 93 | await dbContext.SaveChangesAsync(); 94 | } 95 | } 96 | } 97 | ``` -------------------------------------------------------------------------------- /docs/warnings.md: -------------------------------------------------------------------------------- 1 | - Efcore does not natively support row level locking, this makes it possible for two instances of your app to compete over the next available message to be processed, potentially wasting cycles. For this reason it is reccomended that you only use `AsyncMonolith.Ef` when you are running a single instance of your app OR for development purposes. Using `AsyncMonlith.PostgreSql` / `AsyncMonolith.MySql` / `AsyncMonolith.MsSql` / `AsyncMonolith.MariaDb` will allow the system to lock rows ensuring they are only retrieved and processed once. 2 | - Test your desired throughput 3 | - All consumers must finish executing for a batch of messages before the next batch is started, therefore it is not currently reccomended to execute long running tasks inside your consumers. If you have a need for this please get in touch. -------------------------------------------------------------------------------- /launchSettings.json: -------------------------------------------------------------------------------- 1 | { 2 | "profiles": { 3 | "Docker Compose": { 4 | "commandName": "DockerCompose", 5 | "commandVersion": "1.0", 6 | "serviceActions": { 7 | "demo": "StartDebugging" 8 | } 9 | } 10 | } 11 | } -------------------------------------------------------------------------------- /mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: AsyncMonolith Docs 2 | site_url: https://timmoth.github.io/AsyncMonolith/ 3 | site_description: Lightweight asynchronous processes and scheduling. 4 | site_author: Timmoth 5 | repo_url: https://github.com/Timmoth/AsyncMonolith/ 6 | edit_uri: edit/main/docs 7 | 8 | theme: 9 | name: readthedocs 10 | highlightjs: true 11 | logo: assets/logo.png 12 | hljs_languages: 13 | - csharp 14 | nav: 15 | - Overview ✅: index.md 16 | - Quick start ▶️: quickstart.md 17 | - Internals 🧠: internals.md 18 | - Releases 📒: releases.md 19 | - Warnings ⚠️: warnings.md 20 | - Support 🛟: support.md 21 | - Demo App ✨: demo.md 22 | - Contributing 🙏: contributing.md 23 | - Tests 🐞: tests.md 24 | - Guides: 25 | - Producing messages: guides/producing-messages.md 26 | - Scheduling messages: guides/scheduling-messages.md 27 | - Consuming messages: guides/consuming-messages.md 28 | - Changing messages: guides/changing-messages.md 29 | - Opentelemetry: guides/opentelemetry.md 30 | 31 | - Posts: 32 | - What is the Transactional Outbox?: posts/transactional-outbox.md 33 | - What is the Mediator pattern?: posts/mediator.md 34 | - What is Idempotency?: posts/idempotency.md 35 | 36 | markdown_extensions: 37 | - tables 38 | - admonition 39 | - pymdownx.details 40 | - pymdownx.superfences 41 | --------------------------------------------------------------------------------