diff --git a/Brighter.sln b/Brighter.sln index ac7d8f0d5a..b311584b93 100644 --- a/Brighter.sln +++ b/Brighter.sln @@ -315,6 +315,30 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Salutation_Sweeper", "sampl EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Paramore.Brighter.Locking.MsSql", "src\Paramore.Brighter.Locking.MsSql\Paramore.Brighter.Locking.MsSql.csproj", "{758EE237-C722-4A0A-908C-2D08C1E59025}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Paramore.Brighter.MessageScheduler.Quartz", "src\Paramore.Brighter.MessageScheduler.Quartz\Paramore.Brighter.MessageScheduler.Quartz.csproj", "{097CB927-EC20-413A-85C9-61E6380814CA}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ParamoreBrighter.Quartz.Tests", "tests\ParamoreBrighter.Quartz.Tests\ParamoreBrighter.Quartz.Tests.csproj", "{4469AEE3-B460-4948-A0A5-B9480EE70EA4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Paramore.Brighter.MessageScheduler.Aws", "src\Paramore.Brighter.MessageScheduler.Aws\Paramore.Brighter.MessageScheduler.Aws.csproj", "{28C2529C-EF15-4C86-A2CC-9EE326423A77}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Scheduler", "Scheduler", "{8E414D7F-8DF0-4608-B47C-19213DB7E2B0}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Quartz", "Quartz", "{D5F5A100-F524-4020-B157-298EDC0910F4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreetingsReceiverConsole", "samples\Scheduler\QuartzTaskQueue\GreetingsReceiverConsole\GreetingsReceiverConsole.csproj", "{3779A9C1-2E47-4CB4-B569-75752CA8C419}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreetingsPumper", "samples\Scheduler\QuartzTaskQueue\GreetingsPumper\GreetingsPumper.csproj", "{FADD3869-F9A1-41EA-8DB2-04AFB61A6635}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Greetings", "samples\Scheduler\QuartzTaskQueue\Greetings\Greetings.csproj", "{50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Aws", "Aws", "{966314C4-D989-4E50-85C4-8091189495E9}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Greetings", "samples\Scheduler\AwsTaskQueue\Greetings\Greetings.csproj", "{85AB9510-93CC-4D10-A179-A30654765E59}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreetingsPumper", "samples\Scheduler\AwsTaskQueue\GreetingsPumper\GreetingsPumper.csproj", "{5C27F959-455A-408C-8248-D7EC848B633E}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "GreetingsReceiverConsole", "samples\Scheduler\AwsTaskQueue\GreetingsReceiverConsole\GreetingsReceiverConsole.csproj", "{F8A3AE7E-B232-4794-B23F-772F48D8636F}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -1765,6 +1789,114 @@ Global {758EE237-C722-4A0A-908C-2D08C1E59025}.Release|Mixed Platforms.Build.0 = Release|Any CPU {758EE237-C722-4A0A-908C-2D08C1E59025}.Release|x86.ActiveCfg = Release|Any CPU {758EE237-C722-4A0A-908C-2D08C1E59025}.Release|x86.Build.0 = Release|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Debug|x86.ActiveCfg = Debug|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Debug|x86.Build.0 = Debug|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Release|Any CPU.Build.0 = Release|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Release|x86.ActiveCfg = Release|Any CPU + {097CB927-EC20-413A-85C9-61E6380814CA}.Release|x86.Build.0 = Release|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Debug|x86.ActiveCfg = Debug|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Debug|x86.Build.0 = Debug|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Release|Any CPU.Build.0 = Release|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Release|x86.ActiveCfg = Release|Any CPU + {4469AEE3-B460-4948-A0A5-B9480EE70EA4}.Release|x86.Build.0 = Release|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Debug|Any CPU.Build.0 = Debug|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Debug|x86.ActiveCfg = Debug|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Debug|x86.Build.0 = Debug|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Release|Any CPU.ActiveCfg = Release|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Release|Any CPU.Build.0 = Release|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Release|x86.ActiveCfg = Release|Any CPU + {28C2529C-EF15-4C86-A2CC-9EE326423A77}.Release|x86.Build.0 = Release|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Debug|Any CPU.Build.0 = Debug|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Debug|x86.ActiveCfg = Debug|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Debug|x86.Build.0 = Debug|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Release|Any CPU.ActiveCfg = Release|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Release|Any CPU.Build.0 = Release|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Release|x86.ActiveCfg = Release|Any CPU + {3779A9C1-2E47-4CB4-B569-75752CA8C419}.Release|x86.Build.0 = Release|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Debug|Any CPU.Build.0 = Debug|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Debug|x86.ActiveCfg = Debug|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Debug|x86.Build.0 = Debug|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Release|Any CPU.ActiveCfg = Release|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Release|Any CPU.Build.0 = Release|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Release|x86.ActiveCfg = Release|Any CPU + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635}.Release|x86.Build.0 = Release|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Debug|x86.ActiveCfg = Debug|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Debug|x86.Build.0 = Debug|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Release|Any CPU.Build.0 = Release|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Release|x86.ActiveCfg = Release|Any CPU + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE}.Release|x86.Build.0 = Release|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Debug|x86.ActiveCfg = Debug|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Debug|x86.Build.0 = Debug|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Release|Any CPU.Build.0 = Release|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Release|x86.ActiveCfg = Release|Any CPU + {85AB9510-93CC-4D10-A179-A30654765E59}.Release|x86.Build.0 = Release|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Debug|x86.ActiveCfg = Debug|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Debug|x86.Build.0 = Debug|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Release|Any CPU.Build.0 = Release|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Release|x86.ActiveCfg = Release|Any CPU + {5C27F959-455A-408C-8248-D7EC848B633E}.Release|x86.Build.0 = Release|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Debug|Mixed Platforms.ActiveCfg = Debug|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Debug|Mixed Platforms.Build.0 = Debug|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Debug|x86.ActiveCfg = Debug|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Debug|x86.Build.0 = Debug|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Release|Any CPU.Build.0 = Release|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Release|Mixed Platforms.ActiveCfg = Release|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Release|Mixed Platforms.Build.0 = Release|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Release|x86.ActiveCfg = Release|Any CPU + {F8A3AE7E-B232-4794-B23F-772F48D8636F}.Release|x86.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -1862,6 +1994,16 @@ Global {915BD9FD-2E29-4E2B-8DA3-A74055F32A20} = {C6B17EFD-4F05-4D45-AF3E-C4F3F790B994} {7D8CE752-CCBB-4868-ADF0-30FF94CA611C} = {11935469-A062-4CFF-9F72-F4F41E14C2B4} {FBAF452E-C0AB-4C4B-9A81-F1ED9616DE2A} = {202BA107-89D5-4868-AC5A-3527114C0109} + {4469AEE3-B460-4948-A0A5-B9480EE70EA4} = {329736D2-BF92-4D06-A7BF-19F4B6B64EDD} + {8E414D7F-8DF0-4608-B47C-19213DB7E2B0} = {235DE1F1-E71B-4817-8E27-3B34FF006E4C} + {D5F5A100-F524-4020-B157-298EDC0910F4} = {8E414D7F-8DF0-4608-B47C-19213DB7E2B0} + {3779A9C1-2E47-4CB4-B569-75752CA8C419} = {D5F5A100-F524-4020-B157-298EDC0910F4} + {FADD3869-F9A1-41EA-8DB2-04AFB61A6635} = {D5F5A100-F524-4020-B157-298EDC0910F4} + {50A08BF4-7B24-42EF-B1EA-3CB9546EB8AE} = {D5F5A100-F524-4020-B157-298EDC0910F4} + {966314C4-D989-4E50-85C4-8091189495E9} = {8E414D7F-8DF0-4608-B47C-19213DB7E2B0} + {85AB9510-93CC-4D10-A179-A30654765E59} = {966314C4-D989-4E50-85C4-8091189495E9} + {5C27F959-455A-408C-8248-D7EC848B633E} = {966314C4-D989-4E50-85C4-8091189495E9} + {F8A3AE7E-B232-4794-B23F-772F48D8636F} = {966314C4-D989-4E50-85C4-8091189495E9} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {8B7C7E31-2E32-4E0D-9426-BC9AF22E9F4C} diff --git a/Directory.Packages.props b/Directory.Packages.props index 63137a1901..9fca4781e0 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -6,6 +6,8 @@ + + @@ -78,6 +80,9 @@ + + + diff --git a/docker-compose-localstack.yaml b/docker-compose-localstack.yaml index 6efa721fd8..5940681f2f 100644 --- a/docker-compose-localstack.yaml +++ b/docker-compose-localstack.yaml @@ -5,9 +5,10 @@ services: image: localstack/localstack environment: # LocalStack configuration: https://docs.localstack.cloud/references/configuration/ - - "SERVICES=s3,sqs,sns,sts,dynamodb" + - "SERVICES=s3,sqs,sns,sts,dynamodb,iam,scheduler" - "DEFAULT_REGION=eu-west-1" - "DEBUG=1" + - "PROVIDER_OVERRIDE_EVENTS=v2" ports: - "4566:4566" # LocalStack Gateway - "4510-4559:4510-4559" # External services port range diff --git a/docs/adr/0024-Scheduling.md b/docs/adr/0024-Scheduling.md new file mode 100644 index 0000000000..fef0235691 --- /dev/null +++ b/docs/adr/0024-Scheduling.md @@ -0,0 +1,89 @@ +# 24. Scoping dependencies inline with lifetime scope + +Date: 2025-01-20 + +## Status + +Proposed + +## Context + +Adding the ability to schedule message (by providing `TimeSpan` or `DateTimeOffset`) give to user flexibility to `Send`, `Publis` and `Post`. + + + +## Decision + +Giving support to schedule message, it's necessary breaking on `IAmACommandProcessor` by adding these methods: + +```c# +public interface IAmACommandProcessor +{ + string SchedulerSend(TimeSpan delay, TRequest request) where TRequest : class, IRequest; + string SchedulerSend(DateTimeOffset delay, TRequest request) where TRequest : class, IRequest; + Task SchedulerSendAsync(TimeSpan delay, TRequest request, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + Task SchedulerSendAsync(DateTimeOffset delay, TRequest request, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + + string SchedulerPublish(TimeSpan delay, TRequest request) where TRequest : class, IRequest; + string SchedulerPublish(DateTimeOffset delay, TRequest request) where TRequest : class, IRequest; + Task SchedulerPublishAsync(TimeSpan delay, TRequest request, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + Task SchedulerPublishsync(DateTimeOffset delay, TRequest request, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + + string SchedulerPost(TimeSpan delay, TRequest request) where TRequest : class, IRequest; + string SchedulerPost(DateTimeOffset delay, TRequest request) where TRequest : class, IRequest; + Task SchedulerPostAsync(TimeSpan delay, TRequest request, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + Task SchedulerPostAsync(DateTimeOffset delay, TRequest request, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; +} +``` + +Scheduling can be break into 2 part (Producer & Consumer): +- Producer -> Producing a message we are going to have a new interface: + +```c# +public interface IAmAMessageScheduler +{ +} + + +public interface IAmAMessageSchedulerAsync : IAmAMessageScheduler, IDisposable +{ + Task ScheduleAsync(DateTimeOffset at, SchedulerFireType fireType, TRequest request, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + Task ScheduleAsync(TimeSpan delay, SchedulerFireType fireType, TRequest request, CancellationToken cancellationToken = default) where TRequest : class, IRequest; + Task CancelSchedulerAsync(string id, CancellationToken cancellationToken = default); +} + + +public interface IAmAMessageSchedulerSync : IAmAMessageScheduler, IDisposable +{ + string Schedule(DateTimeOffset at, SchedulerFireType fireType, TRequest request) where TRequest : class, IRequest; + string Schedule(TimeSpan delay, SchedulerFireType fireType, TRequest request) where TRequest : class, IRequest; + void CancelScheduler(string id); +} +``` + +- Consumer -> To avoid duplication code we are going to introduce a new message and have a handler for that: + +```c# +public class SchedulerMessageFired : Event +{ + ..... +} + + +public class SchedulerMessageFiredHandlerAsync(IAmACommandProcessor processor) : RequestHandlerAsync +{ + .... +} +``` + +So on Scheduler implementation we need to send the SchedulerMessageFired + +```c# +public class JobExecute(IAmACommandProcessor processor) +{ + public async Task ExecuteAsync(Arg arg) + { + await processor.SendAsync(new SchedulerMessageFired{ ... }); + } +} +``` \ No newline at end of file diff --git a/samples/Scheduler/AwsTaskQueue/Greetings/Greetings.csproj b/samples/Scheduler/AwsTaskQueue/Greetings/Greetings.csproj new file mode 100644 index 0000000000..6306699275 --- /dev/null +++ b/samples/Scheduler/AwsTaskQueue/Greetings/Greetings.csproj @@ -0,0 +1,15 @@ + + + net8.0 + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/Scheduler/AwsTaskQueue/Greetings/Ports/CommandHandlers/FarewellEventHandler.cs b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/CommandHandlers/FarewellEventHandler.cs new file mode 100644 index 0000000000..ec8e17b48c --- /dev/null +++ b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/CommandHandlers/FarewellEventHandler.cs @@ -0,0 +1,24 @@ +using System; +using Greetings.Ports.Commands; +using Paramore.Brighter; + +namespace Greetings.Ports.CommandHandlers +{ + public class FarewellEventHandler : RequestHandler + { + public override FarewellEvent Handle(FarewellEvent @event) + { + Console.BackgroundColor = ConsoleColor.Blue; + Console.ForegroundColor = ConsoleColor.White; + + Console.WriteLine("Received Farewell. Message Follows"); + Console.WriteLine("----------------------------------"); + Console.WriteLine(@event.Farewell); + Console.WriteLine("----------------------------------"); + Console.WriteLine("Message Ends"); + + Console.ResetColor(); + return base.Handle(@event); + } + } +} diff --git a/samples/Scheduler/AwsTaskQueue/Greetings/Ports/CommandHandlers/GreetingEventHandler.cs b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/CommandHandlers/GreetingEventHandler.cs new file mode 100644 index 0000000000..a5f5fa2a1b --- /dev/null +++ b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/CommandHandlers/GreetingEventHandler.cs @@ -0,0 +1,43 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2014 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using Greetings.Ports.Commands; +using Paramore.Brighter; + +namespace Greetings.Ports.CommandHandlers +{ + public class GreetingEventHandler : RequestHandler + { + public override GreetingEvent Handle(GreetingEvent @event) + { + Console.WriteLine("Received Greeting. Message Follows"); + Console.WriteLine("----------------------------------"); + Console.WriteLine(@event.Greeting); + Console.WriteLine("----------------------------------"); + Console.WriteLine("Message Ends"); + return base.Handle(@event); + } + } +} diff --git a/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Commands/FarewellEvent.cs b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Commands/FarewellEvent.cs new file mode 100644 index 0000000000..8f107f3f87 --- /dev/null +++ b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Commands/FarewellEvent.cs @@ -0,0 +1,19 @@ +using System; +using Paramore.Brighter; + +namespace Greetings.Ports.Commands +{ + public class FarewellEvent : Event + { + public FarewellEvent() : base(Guid.NewGuid()) + { + } + + public FarewellEvent(string farewell) : base(Guid.NewGuid()) + { + Farewell = farewell; + } + + public string Farewell { get; set; } + } +} diff --git a/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Commands/GreetingEvent.cs b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Commands/GreetingEvent.cs new file mode 100644 index 0000000000..7b07b481f8 --- /dev/null +++ b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Commands/GreetingEvent.cs @@ -0,0 +1,41 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2015 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using Paramore.Brighter; + +namespace Greetings.Ports.Commands +{ + public class GreetingEvent : Event + { + public GreetingEvent() : base(Guid.NewGuid()) { } + + public GreetingEvent(string greeting) : base(Guid.NewGuid()) + { + Greeting = greeting; + } + + public string Greeting { get; set; } + } +} diff --git a/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Mappers/FarewellEventMessageMapper.cs b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Mappers/FarewellEventMessageMapper.cs new file mode 100644 index 0000000000..f59152f38d --- /dev/null +++ b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Mappers/FarewellEventMessageMapper.cs @@ -0,0 +1,51 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2014 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System.Text.Json; +using Greetings.Ports.Commands; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.AWSSQS; + +namespace Greetings.Ports.Mappers +{ + public class FarewellEventMessageMapper : IAmAMessageMapper + { + public IRequestContext Context { get; set; } + + public Message MapToMessage(FarewellEvent request, Publication publication) + { + var header = new MessageHeader(messageId: request.Id, topic: publication.Topic, messageType: MessageType.MT_EVENT); + var body = new MessageBody(JsonSerializer.Serialize(request, JsonSerialisationOptions.Options)); + var message = new Message(header, body); + return message; + } + + public FarewellEvent MapToRequest(Message message) + { + var greetingCommand = JsonSerializer.Deserialize(message.Body.Value, JsonSerialisationOptions.Options); + + return greetingCommand; + } + } +} diff --git a/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Mappers/GreetingEventMessageMapper.cs b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Mappers/GreetingEventMessageMapper.cs new file mode 100644 index 0000000000..c5eeb3f260 --- /dev/null +++ b/samples/Scheduler/AwsTaskQueue/Greetings/Ports/Mappers/GreetingEventMessageMapper.cs @@ -0,0 +1,51 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2014 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System.Text.Json; +using Greetings.Ports.Commands; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.AWSSQS; + +namespace Greetings.Ports.Mappers +{ + public class GreetingEventMessageMapper : IAmAMessageMapper + { + public IRequestContext Context { get; set; } + + public Message MapToMessage(GreetingEvent request, Publication publication) + { + var header = new MessageHeader(messageId: request.Id, topic: publication.Topic, messageType: MessageType.MT_EVENT); + var body = new MessageBody(JsonSerializer.Serialize(request, JsonSerialisationOptions.Options)); + var message = new Message(header, body); + return message; + } + + public GreetingEvent MapToRequest(Message message) + { + var greetingCommand = JsonSerializer.Deserialize(message.Body.Value, JsonSerialisationOptions.Options); + + return greetingCommand; + } + } +} diff --git a/samples/Scheduler/AwsTaskQueue/GreetingsPumper/GreetingsPumper.csproj b/samples/Scheduler/AwsTaskQueue/GreetingsPumper/GreetingsPumper.csproj new file mode 100644 index 0000000000..2529e9d4dc --- /dev/null +++ b/samples/Scheduler/AwsTaskQueue/GreetingsPumper/GreetingsPumper.csproj @@ -0,0 +1,23 @@ + + + + Exe + net8.0 + + + + + + + + + + + + + + + + + + diff --git a/samples/Scheduler/AwsTaskQueue/GreetingsPumper/Program.cs b/samples/Scheduler/AwsTaskQueue/GreetingsPumper/Program.cs new file mode 100644 index 0000000000..a04c562428 --- /dev/null +++ b/samples/Scheduler/AwsTaskQueue/GreetingsPumper/Program.cs @@ -0,0 +1,116 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime.CredentialManagement; +using Greetings.Ports.Commands; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Aws; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Paramore.Brighter.Scheduler.Events; +using Serilog; + +namespace GreetingsPumper; + +class Program +{ + private static async Task Main(string[] args) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + + var host = new HostBuilder() + .ConfigureServices((hostContext, services) => + + { + if (new CredentialProfileStoreChain().TryGetAWSCredentials("default", out var credentials)) + { + var awsConnection = new AWSMessagingGatewayConnection(credentials, RegionEndpoint.USEast1, + cfg => + { + var serviceURL = + "http://localhost:4566/"; // Environment.GetEnvironmentVariable("LOCALSTACK_SERVICE_URL"); + if (!string.IsNullOrWhiteSpace(serviceURL)) + { + cfg.ServiceURL = serviceURL; + } + }); + + var producerRegistry = new SnsProducerRegistryFactory( + awsConnection, + [ + new SnsPublication + { + Topic = new RoutingKey(typeof(GreetingEvent).FullName + .ToValidSNSTopicName()), + RequestType = typeof(GreetingEvent) + }, + new SnsPublication + { + Topic = new RoutingKey( + typeof(FarewellEvent).FullName.ToValidSNSTopicName(true)), + SnsAttributes = new SnsAttributes { Type = SnsSqsType.Fifo }, + RequestType = typeof(FarewellEvent) + }, + new SnsPublication + { + Topic = new RoutingKey(typeof(FireSchedulerMessage).FullName.ToValidSNSTopicName()), + RequestType = typeof(FireSchedulerMessage) + } + ] + ).Create(); + + services.AddBrighter() + .UseExternalBus(configure => + { + configure.ProducerRegistry = producerRegistry; + }) + .UseMessageScheduler(new AwsMessageSchedulerFactory(awsConnection, "brighter-scheduler") + { + SchedulerTopicOrQueue = new RoutingKey(typeof(FireSchedulerMessage).FullName.ToValidSNSTopicName()), + OnConflict = OnSchedulerConflict.Overwrite, + GetOrCreateSchedulerId = message => message.Id + }) + .AutoFromAssemblies(typeof(GreetingEvent).Assembly); + } + + services.AddHostedService(); + } + ) + .UseConsoleLifetime() + .UseSerilog() + .Build(); + + await host.RunAsync(); + } + + internal class RunCommandProcessor(IAmACommandProcessor commandProcessor, ILogger logger) : IHostedService + { + public async Task StartAsync(CancellationToken cancellationToken) + { + long loop = 0; + while (!cancellationToken.IsCancellationRequested) + { + loop++; + + logger.LogInformation("Scheduling message #{Loop}", loop); + commandProcessor.Post(new GreetingEvent($"Ian #{loop}")); + + if (loop % 100 != 0) + continue; + + logger.LogInformation("Pausing for breath..."); + await Task.Delay(4000, cancellationToken); + } + } + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } +} diff --git a/samples/Scheduler/AwsTaskQueue/GreetingsReceiverConsole/GreetingsReceiverConsole.csproj b/samples/Scheduler/AwsTaskQueue/GreetingsReceiverConsole/GreetingsReceiverConsole.csproj new file mode 100644 index 0000000000..4ad5c39849 --- /dev/null +++ b/samples/Scheduler/AwsTaskQueue/GreetingsReceiverConsole/GreetingsReceiverConsole.csproj @@ -0,0 +1,21 @@ + + + net8.0 + Exe + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/Scheduler/AwsTaskQueue/GreetingsReceiverConsole/Program.cs b/samples/Scheduler/AwsTaskQueue/GreetingsReceiverConsole/Program.cs new file mode 100644 index 0000000000..514b1d910c --- /dev/null +++ b/samples/Scheduler/AwsTaskQueue/GreetingsReceiverConsole/Program.cs @@ -0,0 +1,115 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2014 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime.CredentialManagement; +using Greetings.Ports.Commands; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Paramore.Brighter.Scheduler.Events; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; +using Paramore.Brighter.ServiceActivator.Extensions.Hosting; +using Serilog; + +namespace GreetingsReceiverConsole; + +public class Program +{ + public static async Task Main(string[] args) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + + var host = new HostBuilder() + .ConfigureServices((_, services) => + + { + var subscriptions = new Subscription[] + { + new SqsSubscription( + new SubscriptionName("paramore.example.fire-scheduler"), + new ChannelName(typeof(FireSchedulerMessage).FullName.ToValidSNSTopicName()), + new RoutingKey(typeof(FireSchedulerMessage).FullName.ToValidSNSTopicName()), + bufferSize: 10, + timeOut: TimeSpan.FromMilliseconds(20), + lockTimeout: 30), + new SqsSubscription( + new SubscriptionName("paramore.example.greeting"), + new ChannelName(typeof(GreetingEvent).FullName.ToValidSNSTopicName()), + new RoutingKey(typeof(GreetingEvent).FullName.ToValidSNSTopicName()), + bufferSize: 10, + timeOut: TimeSpan.FromMilliseconds(20), + lockTimeout: 30), + new SqsSubscription(new SubscriptionName("paramore.example.farewell"), + new ChannelName(typeof(FarewellEvent).FullName.ToValidSNSTopicName(true)), + new RoutingKey(typeof(FarewellEvent).FullName.ToValidSNSTopicName(true)), + bufferSize: 10, + timeOut: TimeSpan.FromMilliseconds(20), + lockTimeout: 30, + sqsType: SnsSqsType.Fifo, + snsAttributes: new SnsAttributes { Type = SnsSqsType.Fifo }) + }; + + //create the gateway + if (new CredentialProfileStoreChain().TryGetAWSCredentials("default", out var credentials)) + { + var serviceURL = + "http://localhost:4566/"; // Environment.GetEnvironmentVariable("LOCALSTACK_SERVICE_URL"); + var region = string.IsNullOrWhiteSpace(serviceURL) + ? RegionEndpoint.EUWest1 + : RegionEndpoint.USEast1; + var awsConnection = new AWSMessagingGatewayConnection(credentials, region, + cfg => + { + if (!string.IsNullOrWhiteSpace(serviceURL)) + { + cfg.ServiceURL = serviceURL; + } + }); + + services.AddServiceActivator(options => + { + options.Subscriptions = subscriptions; + options.DefaultChannelFactory = new ChannelFactory(awsConnection); + }) + .AutoFromAssemblies(); + } + + services.AddHostedService(); + }) + .UseConsoleLifetime() + .UseSerilog() + .Build(); + + await host.RunAsync(); + } +} diff --git a/samples/Scheduler/QuartzTaskQueue/Greetings/Greetings.csproj b/samples/Scheduler/QuartzTaskQueue/Greetings/Greetings.csproj new file mode 100644 index 0000000000..6306699275 --- /dev/null +++ b/samples/Scheduler/QuartzTaskQueue/Greetings/Greetings.csproj @@ -0,0 +1,15 @@ + + + net8.0 + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/CommandHandlers/FarewellEventHandler.cs b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/CommandHandlers/FarewellEventHandler.cs new file mode 100644 index 0000000000..3853b318ca --- /dev/null +++ b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/CommandHandlers/FarewellEventHandler.cs @@ -0,0 +1,23 @@ +using System; +using Greetings.Ports.Commands; +using Paramore.Brighter; + +namespace Greetings.Ports.CommandHandlers; + +public class FarewellEventHandler : RequestHandler +{ + public override FarewellEvent Handle(FarewellEvent @event) + { + Console.BackgroundColor = ConsoleColor.Blue; + Console.ForegroundColor = ConsoleColor.White; + + Console.WriteLine("Received Farewell. Message Follows"); + Console.WriteLine("----------------------------------"); + Console.WriteLine(@event.Farewell); + Console.WriteLine("----------------------------------"); + Console.WriteLine("Message Ends"); + + Console.ResetColor(); + return base.Handle(@event); + } +} \ No newline at end of file diff --git a/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/CommandHandlers/GreetingEventHandler.cs b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/CommandHandlers/GreetingEventHandler.cs new file mode 100644 index 0000000000..d374134ba0 --- /dev/null +++ b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/CommandHandlers/GreetingEventHandler.cs @@ -0,0 +1,42 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2014 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using Greetings.Ports.Commands; +using Paramore.Brighter; + +namespace Greetings.Ports.CommandHandlers; + +public class GreetingEventHandler : RequestHandler +{ + public override GreetingEvent Handle(GreetingEvent @event) + { + Console.WriteLine("Received Greeting. Message Follows"); + Console.WriteLine("----------------------------------"); + Console.WriteLine(@event.Greeting); + Console.WriteLine("----------------------------------"); + Console.WriteLine("Message Ends"); + return base.Handle(@event); + } +} \ No newline at end of file diff --git a/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Commands/FarewellEvent.cs b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Commands/FarewellEvent.cs new file mode 100644 index 0000000000..a0a6340b9b --- /dev/null +++ b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Commands/FarewellEvent.cs @@ -0,0 +1,18 @@ +using System; +using Paramore.Brighter; + +namespace Greetings.Ports.Commands; + +public class FarewellEvent : Event +{ + public FarewellEvent() : base(Guid.NewGuid()) + { + } + + public FarewellEvent(string farewell) : base(Guid.NewGuid()) + { + Farewell = farewell; + } + + public string Farewell { get; set; } +} \ No newline at end of file diff --git a/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Commands/GreetingEvent.cs b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Commands/GreetingEvent.cs new file mode 100644 index 0000000000..bffaff012b --- /dev/null +++ b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Commands/GreetingEvent.cs @@ -0,0 +1,40 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2015 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using Paramore.Brighter; + +namespace Greetings.Ports.Commands; + +public class GreetingEvent : Event +{ + public GreetingEvent() : base(Guid.NewGuid()) { } + + public GreetingEvent(string greeting) : base(Guid.NewGuid()) + { + Greeting = greeting; + } + + public string Greeting { get; set; } +} diff --git a/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Mappers/FarewellEventMessageMapper.cs b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Mappers/FarewellEventMessageMapper.cs new file mode 100644 index 0000000000..ddffaabed8 --- /dev/null +++ b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Mappers/FarewellEventMessageMapper.cs @@ -0,0 +1,50 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2014 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System.Text.Json; +using Greetings.Ports.Commands; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.AWSSQS; + +namespace Greetings.Ports.Mappers; + +public class FarewellEventMessageMapper : IAmAMessageMapper +{ + public IRequestContext? Context { get; set; } + + public Message MapToMessage(FarewellEvent request, Publication publication) + { + var header = new MessageHeader(messageId: request.Id, topic: publication.Topic, messageType: MessageType.MT_EVENT); + var body = new MessageBody(JsonSerializer.Serialize(request, JsonSerialisationOptions.Options)); + var message = new Message(header, body); + return message; + } + + public FarewellEvent MapToRequest(Message message) + { + var greetingCommand = JsonSerializer.Deserialize(message.Body.Value, JsonSerialisationOptions.Options); + + return greetingCommand; + } +} diff --git a/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Mappers/GreetingEventMessageMapper.cs b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Mappers/GreetingEventMessageMapper.cs new file mode 100644 index 0000000000..f56cfe37c4 --- /dev/null +++ b/samples/Scheduler/QuartzTaskQueue/Greetings/Ports/Mappers/GreetingEventMessageMapper.cs @@ -0,0 +1,50 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2014 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System.Text.Json; +using Greetings.Ports.Commands; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.AWSSQS; + +namespace Greetings.Ports.Mappers; + +public class GreetingEventMessageMapper : IAmAMessageMapper +{ + public IRequestContext? Context { get; set; } + + public Message MapToMessage(GreetingEvent request, Publication publication) + { + var header = new MessageHeader(messageId: request.Id, topic: publication.Topic, messageType: MessageType.MT_EVENT); + var body = new MessageBody(JsonSerializer.Serialize(request, JsonSerialisationOptions.Options)); + var message = new Message(header, body); + return message; + } + + public GreetingEvent MapToRequest(Message message) + { + var greetingCommand = JsonSerializer.Deserialize(message.Body.Value, JsonSerialisationOptions.Options); + + return greetingCommand; + } +} diff --git a/samples/Scheduler/QuartzTaskQueue/GreetingsPumper/GreetingsPumper.csproj b/samples/Scheduler/QuartzTaskQueue/GreetingsPumper/GreetingsPumper.csproj new file mode 100644 index 0000000000..f380e315b7 --- /dev/null +++ b/samples/Scheduler/QuartzTaskQueue/GreetingsPumper/GreetingsPumper.csproj @@ -0,0 +1,24 @@ + + + + Exe + net8.0 + + + + + + + + + + + + + + + + + + + diff --git a/samples/Scheduler/QuartzTaskQueue/GreetingsPumper/Program.cs b/samples/Scheduler/QuartzTaskQueue/GreetingsPumper/Program.cs new file mode 100644 index 0000000000..6ecb286867 --- /dev/null +++ b/samples/Scheduler/QuartzTaskQueue/GreetingsPumper/Program.cs @@ -0,0 +1,124 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime.CredentialManagement; +using Greetings.Ports.Commands; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Paramore.Brighter; +using Paramore.Brighter.Extensions.DependencyInjection; +using Paramore.Brighter.MessageScheduler.Quartz; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Quartz; +using Serilog; + +namespace GreetingsPumper; + +class Program +{ + private static async Task Main(string[] args) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + + var host = new HostBuilder() + .ConfigureServices((hostContext, services) => + { + services + .AddSingleton() + .AddQuartz(opt => + { + opt.SchedulerId = "QuartzBrighter"; + opt.SchedulerName = "QuartzBrighter"; + opt.UseSimpleTypeLoader(); + opt.UseInMemoryStore(); + }) + .AddQuartzHostedService(opt => + { + opt.WaitForJobsToComplete = true; + }); + + if (new CredentialProfileStoreChain().TryGetAWSCredentials("default", out var credentials)) + { + var awsConnection = new AWSMessagingGatewayConnection(credentials, RegionEndpoint.USEast1, + cfg => + { + var serviceURL = + "http://localhost:4566/"; // Environment.GetEnvironmentVariable("LOCALSTACK_SERVICE_URL"); + if (!string.IsNullOrWhiteSpace(serviceURL)) + { + cfg.ServiceURL = serviceURL; + } + }); + + var producerRegistry = new SnsProducerRegistryFactory( + awsConnection, + [ + new SnsPublication + { + Topic = new RoutingKey(typeof(GreetingEvent).FullName.ToValidSNSTopicName()), + RequestType = typeof(GreetingEvent) + }, + new SnsPublication + { + Topic = + new RoutingKey(typeof(FarewellEvent).FullName.ToValidSNSTopicName(true)), + SnsAttributes = new SnsAttributes { Type = SnsSqsType.Fifo } + } + ] + ).Create(); + + services.AddBrighter() + .UseExternalBus((configure) => + { + configure.ProducerRegistry = producerRegistry; + }) + .UseMessageScheduler(provider => + { + var factory = provider.GetRequiredService(); + return new QuartzMessageSchedulerFactory( + factory.GetScheduler().GetAwaiter().GetResult()); + }) + .AutoFromAssemblies(typeof(GreetingEvent).Assembly); + } + + services.AddHostedService(); + } + ) + .UseConsoleLifetime() + .UseSerilog() + .Build(); + + await host.RunAsync(); + } + + internal class RunCommandProcessor(IAmACommandProcessor commandProcessor, ILogger logger) + : BackgroundService + { + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + long loop = 0; + while (!stoppingToken.IsCancellationRequested) + { + loop++; + + logger.LogInformation("Scheduling message #{Loop}", loop); + commandProcessor.SchedulerPost(new GreetingEvent($"Scheduler message Ian #{loop}"), + TimeSpan.FromMinutes(1)); + + if (loop % 100 != 0) + { + continue; + } + + logger.LogInformation("Pausing for breath..."); + await Task.Delay(4000, stoppingToken); + } + } + } +} diff --git a/samples/Scheduler/QuartzTaskQueue/GreetingsReceiverConsole/GreetingsReceiverConsole.csproj b/samples/Scheduler/QuartzTaskQueue/GreetingsReceiverConsole/GreetingsReceiverConsole.csproj new file mode 100644 index 0000000000..c105a1f402 --- /dev/null +++ b/samples/Scheduler/QuartzTaskQueue/GreetingsReceiverConsole/GreetingsReceiverConsole.csproj @@ -0,0 +1,20 @@ + + + net8.0 + Exe + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/Scheduler/QuartzTaskQueue/GreetingsReceiverConsole/Program.cs b/samples/Scheduler/QuartzTaskQueue/GreetingsReceiverConsole/Program.cs new file mode 100644 index 0000000000..01b06720db --- /dev/null +++ b/samples/Scheduler/QuartzTaskQueue/GreetingsReceiverConsole/Program.cs @@ -0,0 +1,108 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2014 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using System.Threading.Tasks; +using Amazon; +using Amazon.Runtime.CredentialManagement; +using Greetings.Ports.Commands; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Paramore.Brighter; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Paramore.Brighter.ServiceActivator.Extensions.DependencyInjection; +using Paramore.Brighter.ServiceActivator.Extensions.Hosting; +using Serilog; + +namespace GreetingsReceiverConsole; + +public class Program +{ + public static async Task Main(string[] args) + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Information() + .Enrich.FromLogContext() + .WriteTo.Console() + .CreateLogger(); + + var host = new HostBuilder() + .ConfigureServices((_, services) => + + { + var subscriptions = new Subscription[] + { + new SqsSubscription( + new SubscriptionName("paramore.example.greeting"), + new ChannelName(typeof(GreetingEvent).FullName.ToValidSNSTopicName()), + new RoutingKey(typeof(GreetingEvent).FullName.ToValidSNSTopicName()), + bufferSize: 10, + timeOut: TimeSpan.FromMilliseconds(20), + lockTimeout: 30, + messagePumpType: MessagePumpType.Reactor), + new SqsSubscription(new SubscriptionName("paramore.example.farewell"), + new ChannelName(typeof(FarewellEvent).FullName.ToValidSNSTopicName(true)), + new RoutingKey(typeof(FarewellEvent).FullName.ToValidSNSTopicName(true)), + bufferSize: 10, + timeOut: TimeSpan.FromMilliseconds(20), + lockTimeout: 30, + sqsType: SnsSqsType.Fifo, + snsAttributes: new SnsAttributes { Type = SnsSqsType.Fifo }, + messagePumpType: MessagePumpType.Reactor) + }; + + //create the gateway + if (new CredentialProfileStoreChain().TryGetAWSCredentials("default", out var credentials)) + { + var serviceURL = "http://localhost:4566/"; // Environment.GetEnvironmentVariable("LOCALSTACK_SERVICE_URL"); + var region = string.IsNullOrWhiteSpace(serviceURL) + ? RegionEndpoint.EUWest1 + : RegionEndpoint.USEast1; + var awsConnection = new AWSMessagingGatewayConnection(credentials, region, + cfg => + { + if (!string.IsNullOrWhiteSpace(serviceURL)) + { + cfg.ServiceURL = serviceURL; + } + }); + + services.AddServiceActivator(options => + { + options.Subscriptions = subscriptions; + options.DefaultChannelFactory = new ChannelFactory(awsConnection); + }) + .AutoFromAssemblies(); + } + + services.AddHostedService(); + }) + .UseConsoleLifetime() + .UseSerilog() + .Build(); + + await host.RunAsync(); + } +} diff --git a/samples/TaskQueue/AWSTaskQueue/GreetingsSender/Program.cs b/samples/TaskQueue/AWSTaskQueue/GreetingsSender/Program.cs index 23da574b27..961ac3e14d 100644 --- a/samples/TaskQueue/AWSTaskQueue/GreetingsSender/Program.cs +++ b/samples/TaskQueue/AWSTaskQueue/GreetingsSender/Program.cs @@ -54,7 +54,7 @@ static async Task Main(string[] args) if (new CredentialProfileStoreChain().TryGetAWSCredentials("default", out var credentials)) { - var serviceURL = Environment.GetEnvironmentVariable("LOCALSTACK_SERVICE_URL"); + var serviceURL = "http://localhost:4566/"; // Environment.GetEnvironmentVariable("LOCALSTACK_SERVICE_URL"); var region = string.IsNullOrWhiteSpace(serviceURL) ? RegionEndpoint.EUWest1 : RegionEndpoint.USEast1; var awsConnection = new AWSMessagingGatewayConnection(credentials, region, cfg => { @@ -85,8 +85,9 @@ static async Task Main(string[] args) } ).Create(); - serviceCollection.AddBrighter() - .UseExternalBus((configure) => + serviceCollection + .AddBrighter() + .UseExternalBus(configure => { configure.ProducerRegistry = producerRegistry; }) @@ -94,7 +95,7 @@ static async Task Main(string[] args) var serviceProvider = serviceCollection.BuildServiceProvider(); - var commandProcessor = serviceProvider.GetService(); + var commandProcessor = serviceProvider.GetRequiredService(); commandProcessor.Post(new GreetingEvent("Ian says: Hi there!")); commandProcessor.Post(new FarewellEvent("Ian says: See you later!")); diff --git a/samples/TaskQueue/RMQTaskQueue/GreetingsSender/Program.cs b/samples/TaskQueue/RMQTaskQueue/GreetingsSender/Program.cs index d671b4b63b..a8ed45bd41 100644 --- a/samples/TaskQueue/RMQTaskQueue/GreetingsSender/Program.cs +++ b/samples/TaskQueue/RMQTaskQueue/GreetingsSender/Program.cs @@ -74,7 +74,8 @@ static void Main(string[] args) } }).Create(); - serviceCollection.AddBrighter() + serviceCollection + .AddBrighter() .UseExternalBus((configure) => { configure.ProducerRegistry = producerRegistry; diff --git a/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs b/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs index c4e2fd64e9..3638544784 100644 --- a/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs +++ b/src/Paramore.Brighter.Extensions.DependencyInjection/IBrighterBuilder.cs @@ -96,7 +96,6 @@ public interface IBrighterBuilder /// IPolicyRegistry PolicyRegistry { get; set; } - /// /// The IoC container to populate /// diff --git a/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionExtensions.cs b/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionExtensions.cs index 035e195b73..0c488bd86e 100644 --- a/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionExtensions.cs +++ b/src/Paramore.Brighter.Extensions.DependencyInjection/ServiceCollectionExtensions.cs @@ -24,6 +24,7 @@ THE SOFTWARE. */ #endregion using System; +using System.Linq; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Logging; @@ -103,9 +104,7 @@ public static IBrighterBuilder BrighterHandlerBuilder(IServiceCollection service if (options.PolicyRegistry == null) policyRegistry = new DefaultPolicy(); else policyRegistry = AddDefaults(options.PolicyRegistry); - services.TryAdd(new ServiceDescriptor(typeof(IAmACommandProcessor), - (serviceProvider) => (IAmACommandProcessor)BuildCommandProcessor(serviceProvider), - options.CommandProcessorLifetime)); + services.TryAdd(new ServiceDescriptor(typeof(IAmACommandProcessor), BuildCommandProcessor, options.CommandProcessorLifetime)); return new ServiceCollectionBrighterBuilder( services, @@ -147,12 +146,19 @@ public static IBrighterBuilder UseExternalBus( var busConfiguration = new ExternalBusConfiguration(); configure?.Invoke(busConfiguration); - + if (busConfiguration.ProducerRegistry == null) + { throw new ConfigurationException("An external bus must have an IAmAProducerRegistry"); + } brighterBuilder.Services.TryAddSingleton(busConfiguration.ProducerRegistry); + if (busConfiguration.MessageSchedulerFactory != null) + { + UseMessageScheduler(brighterBuilder, busConfiguration.MessageSchedulerFactory); + } + //default to using System Transactions if nothing provided, so we always technically can share the outbox transaction Type transactionProvider = busConfiguration.TransactionProvider ?? typeof(CommittableTransactionProvider); @@ -160,8 +166,12 @@ public static IBrighterBuilder UseExternalBus( Type transactionProviderInterface = typeof(IAmABoxTransactionProvider<>); Type transactionType = null; foreach (Type i in transactionProvider.GetInterfaces()) + { if (i.IsGenericType && i.GetGenericTypeDefinition() == transactionProviderInterface) + { transactionType = i.GetGenericArguments()[0]; + } + } if (transactionType == null) throw new ConfigurationException( @@ -179,11 +189,7 @@ public static IBrighterBuilder UseExternalBus( transactionProvider, serviceLifetime); //we always need an outbox in case of producer callbacks - var outbox = busConfiguration.Outbox; - if (outbox == null) - { - outbox = new InMemoryOutbox(TimeProvider.System); - } + var outbox = busConfiguration.Outbox ?? new InMemoryOutbox(TimeProvider.System); //we create the outbox from interfaces from the determined transaction type to prevent the need //to pass generic types as we know the transaction provider type @@ -226,7 +232,30 @@ public static IBrighterBuilder UseExternalBus( return brighterBuilder; } + + /// + /// An external message scheduler factory + /// + /// The builder. + /// The message scheduler factory + /// + public static IBrighterBuilder UseMessageScheduler(this IBrighterBuilder builder, IAmAMessageSchedulerFactory factory) + { + builder.Services.AddSingleton(factory); + return builder; + } + /// + /// An external message scheduler factory + /// + /// The builder. + /// The message scheduler factory + /// + public static IBrighterBuilder UseMessageScheduler(this IBrighterBuilder builder, Func factory) + { + builder.Services.AddSingleton(factory); + return builder; + } private static INeedInstrumentation AddEventBus( IServiceProvider provider, @@ -236,6 +265,7 @@ private static INeedInstrumentation AddEventBus( var eventBus = provider.GetService(); var eventBusConfiguration = provider.GetService(); var serviceActivatorOptions = provider.GetService(); + var messageSchedulerFactory = eventBusConfiguration?.MessageSchedulerFactory ?? provider.GetService(); INeedInstrumentation instrumentationBuilder = null; var hasEventBus = eventBus != null; @@ -248,10 +278,10 @@ private static INeedInstrumentation AddEventBus( instrumentationBuilder = messagingBuilder.ExternalBus( ExternalBusType.FireAndForget, eventBus, - eventBusConfiguration.ResponseChannelFactory, + eventBusConfiguration!.ResponseChannelFactory, eventBusConfiguration.ReplyQueueSubscriptions, - serviceActivatorOptions?.InboxConfiguration - ); + serviceActivatorOptions?.InboxConfiguration, + messageSchedulerFactory); } if (hasEventBus && useRpc) @@ -259,9 +289,10 @@ private static INeedInstrumentation AddEventBus( instrumentationBuilder = messagingBuilder.ExternalBus( ExternalBusType.RPC, eventBus, - eventBusConfiguration.ResponseChannelFactory, + eventBusConfiguration!.ResponseChannelFactory, eventBusConfiguration.ReplyQueueSubscriptions, - serviceActivatorOptions?.InboxConfiguration + serviceActivatorOptions?.InboxConfiguration, + messageSchedulerFactory ); } @@ -281,7 +312,7 @@ private static IPolicyRegistry AddDefaults(IPolicyRegistry polic return policyRegistry; } - private static object BuildCommandProcessor(IServiceProvider provider) + private static IAmACommandProcessor BuildCommandProcessor(IServiceProvider provider) { var loggerFactory = provider.GetService(); ApplicationLogging.LoggerFactory = loggerFactory; @@ -305,20 +336,24 @@ private static object BuildCommandProcessor(IServiceProvider provider) var messagingBuilder = options.PolicyRegistry == null ? policyBuilder.DefaultPolicy() : policyBuilder.Policies(options.PolicyRegistry); - - INeedInstrumentation instrumentationBuilder = AddEventBus(provider, messagingBuilder, useRequestResponse); - - var tracer = provider.GetService(); - - var contextBuilder = instrumentationBuilder.ConfigureInstrumentation(tracer, options.InstrumentationOptions); - var requestContextFactory = provider.GetService(); + var command = AddEventBus(provider, messagingBuilder, useRequestResponse) + .ConfigureInstrumentation(provider.GetService(), options.InstrumentationOptions) + .RequestContextFactory(provider.GetService()) + .MessageSchedulerFactory(provider.GetService()) + .Build(); - var builder = contextBuilder.RequestContextFactory(requestContextFactory); - - var commandProcessor = builder.Build(); + var eventBusConfiguration = provider.GetService(); + var producerRegistry = provider.GetService(); + var messageSchedulerFactory = eventBusConfiguration?.MessageSchedulerFactory ?? provider.GetService(); + if (messageSchedulerFactory != null && producerRegistry != null) + { + producerRegistry + .Producers + .Each(x => x.Scheduler ??= messageSchedulerFactory.Create(command)); + } - return commandProcessor; + return command; } private static IAmAnOutboxProducerMediator BuildOutBoxProducerMediator(IServiceProvider serviceProvider, diff --git a/src/Paramore.Brighter.MessageScheduler.Aws/AWSClientFactoryExtensions.cs b/src/Paramore.Brighter.MessageScheduler.Aws/AWSClientFactoryExtensions.cs new file mode 100644 index 0000000000..a57d86e50e --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Aws/AWSClientFactoryExtensions.cs @@ -0,0 +1,24 @@ +using Amazon.IdentityManagement; +using Amazon.Scheduler; +using Paramore.Brighter.MessagingGateway.AWSSQS; + +namespace Paramore.Brighter.MessageScheduler.Aws; + +public static class AWSClientFactoryExtensions +{ + public static IAmazonScheduler CreateSchedulerClient(this AWSClientFactory factory) + { + var config = new AmazonSchedulerConfig { RegionEndpoint = factory.RegionEndpoint }; + + factory.ClientConfigAction?.Invoke(config); + + return new AmazonSchedulerClient(factory.Credentials, config); + } + + public static AmazonIdentityManagementServiceClient CreateIdentityClient(this AWSClientFactory factory) + { + var config = new AmazonIdentityManagementServiceConfig { RegionEndpoint = factory.RegionEndpoint }; + factory.ClientConfigAction?.Invoke(config); + return new AmazonIdentityManagementServiceClient(factory.Credentials, config); + } +} diff --git a/src/Paramore.Brighter.MessageScheduler.Aws/AwsMessageScheduler.cs b/src/Paramore.Brighter.MessageScheduler.Aws/AwsMessageScheduler.cs new file mode 100644 index 0000000000..c61aa4040c --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Aws/AwsMessageScheduler.cs @@ -0,0 +1,402 @@ +using System.Collections.Concurrent; +using System.Text.Json; +using Amazon; +using Amazon.Scheduler; +using Amazon.Scheduler.Model; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Paramore.Brighter.Scheduler.Events; +using Paramore.Brighter.Tasks; +using ResourceNotFoundException = Amazon.Scheduler.Model.ResourceNotFoundException; + +namespace Paramore.Brighter.MessageScheduler.Aws; + +public class AwsMessageScheduler( + AWSClientFactory factory, + Func getOrCreateSchedulerId, + Scheduler scheduler, + SchedulerGroup schedulerGroup) : IAmAMessageSchedulerAsync, IAmAMessageSchedulerSync +{ + private static readonly ConcurrentDictionary s_checkedGroup = new(); + private static readonly ConcurrentDictionary s_queueUrl = new(); + private static readonly ConcurrentDictionary s_topic = new(); + + /// + public async Task ScheduleAsync(Message message, DateTimeOffset at, + CancellationToken cancellationToken = default) + => await ScheduleAsync(message, at, true, cancellationToken); + + /// + public async Task ScheduleAsync(Message message, TimeSpan delay, + CancellationToken cancellationToken = default) + => await ScheduleAsync(message, DateTimeOffset.UtcNow.Add(delay), cancellationToken); + + /// + public async Task ReSchedulerAsync(string schedulerId, DateTimeOffset at, + CancellationToken cancellationToken = default) + { + try + { + using var client = factory.CreateSchedulerClient(); + var res = await client.GetScheduleAsync( + new GetScheduleRequest { Name = schedulerId, GroupName = schedulerGroup.Name }, cancellationToken); + + await client.UpdateScheduleAsync( + new UpdateScheduleRequest + { + Name = schedulerId, + GroupName = schedulerGroup.Name, + Target = res.Target, + ScheduleExpression = AtExpression(at), + ScheduleExpressionTimezone = "UTC", + State = ScheduleState.ENABLED, + ActionAfterCompletion = ActionAfterCompletion.DELETE, + FlexibleTimeWindow = scheduler.ToFlexibleTimeWindow() + }, cancellationToken); + + return true; + } + catch (ResourceNotFoundException) + { + // Case the scheduler doesn't exist we are going to ignore it + return false; + } + } + + /// + public async Task ReSchedulerAsync(string schedulerId, TimeSpan delay, + CancellationToken cancellationToken = default) + => await ReSchedulerAsync(schedulerId, DateTimeOffset.UtcNow.Add(delay), cancellationToken); + + /// + public async Task CancelAsync(string id, CancellationToken cancellationToken = default) + { + try + { + using var client = factory.CreateSchedulerClient(); + await client.DeleteScheduleAsync(new DeleteScheduleRequest { Name = id, GroupName = schedulerGroup.Name }, + cancellationToken); + } + catch (ResourceNotFoundException) + { + // Case the scheduler doesn't exist we are going to ignore it + } + } + + private ValueTask EnsureGroupExistsAsync(CancellationToken cancellationToken) + { + if (schedulerGroup.MakeSchedulerGroup == OnMissingSchedulerGroup.Assume || + s_checkedGroup.ContainsKey(schedulerGroup.Name)) + { + return new ValueTask(); + } + + return new ValueTask(CreateSchedulerGroup(cancellationToken)); + } + + + private async Task CreateSchedulerGroup(CancellationToken cancellationToken) + { + var client = factory.CreateSchedulerClient(); + try + { + _ = await client.GetScheduleGroupAsync(new GetScheduleGroupRequest { Name = schedulerGroup.Name }, + cancellationToken); + } + catch (ResourceNotFoundException) + { + try + { + _ = await client.CreateScheduleGroupAsync( + new CreateScheduleGroupRequest { Name = schedulerGroup.Name, Tags = schedulerGroup.Tags }, + cancellationToken); + } + catch (ConflictException) + { + // Ignoring due concurrency issue + } + } + + s_checkedGroup.TryAdd(schedulerGroup.Name, true); + } + + private async Task CreateTargetAsync(string id, Message message, bool async) + { + var roleArn = scheduler.RoleArn; + if (scheduler.UseMessageTopicAsTarget) + { + var topicArn = await GetTopicAsync(message); + if (!string.IsNullOrEmpty(topicArn)) + { + return new Target + { + RoleArn = roleArn, + Arn = "arn:aws:scheduler:::aws-sdk:sns:publish", + Input = JsonSerializer.Serialize(ToPublishRequest(topicArn, message)) + }; + } + + var queueUrl = await GetQueueAsync(message); + if (!string.IsNullOrEmpty(queueUrl)) + { + return new Target + { + RoleArn = roleArn, + Arn = "arn:aws:scheduler:::aws-sdk:sqs:sendMessage", + Input = JsonSerializer.Serialize(ToSendMessageRequest(queueUrl, message)) + }; + } + } + + var schedulerMessage = new Message + { + Header = new MessageHeader(id, scheduler.Topic, MessageType.MT_COMMAND, subject: nameof(FireSchedulerMessage)), + Body = new MessageBody(JsonSerializer.Serialize(new FireSchedulerMessage{Id = id, Async = async, Message = message}, JsonSerialisationOptions.Options)) + }; + + if (!string.IsNullOrEmpty(scheduler.TopicArn)) + { + return new Target + { + RoleArn = roleArn, + Arn = "arn:aws:scheduler:::aws-sdk:sns:publish", + Input = JsonSerializer.Serialize(ToPublishRequest(scheduler.TopicArn, schedulerMessage)) + }; + } + + if (!string.IsNullOrWhiteSpace(scheduler.QueueUrl)) + { + return new Target + { + RoleArn = roleArn, + Arn = "arn:aws:scheduler:::aws-sdk:sqs:sendMessage", + Input = JsonSerializer.Serialize(ToSendMessageRequest(scheduler.QueueUrl, schedulerMessage)) + }; + } + + throw new InvalidOperationException("Queue or Topic for Scheduler message not found"); + } + + private ValueTask GetTopicAsync(Message message) + { + if (s_topic.TryGetValue(message.Header.Topic, out var topicArn)) + { + return new ValueTask(topicArn); + } + + return new ValueTask(GetTopicArnAsync(message.Header.Topic)); + + + async Task GetTopicArnAsync(string topicName) + { + if (Arn.IsArn(topicName)) + { + s_topic.TryAdd(topicName, topicName); + return topicName; + } + + using var client = factory.CreateSnsClient(); + var topic = await client.FindTopicAsync(topicName); + s_topic.TryAdd(topicName, topic?.TopicArn); + return topic?.TopicArn; + } + } + + private ValueTask GetQueueAsync(Message message) + { + if (s_queueUrl.TryGetValue(message.Header.Topic, out var queueUrl)) + { + return new ValueTask(queueUrl); + } + + return new ValueTask(GetQueueUrlAsync(message.Header.Topic)); + + async Task GetQueueUrlAsync(string queueName) + { + if (Uri.TryCreate(queueName, UriKind.Absolute, out _)) + { + s_queueUrl.TryAdd(queueName, queueName); + return queueName; + } + + using var client = factory.CreateSqsClient(); + try + { + var queue = await client.GetQueueUrlAsync(queueName); + s_queueUrl.TryAdd(queueName, queue.QueueUrl); + return queue.QueueUrl; + } + catch + { + s_queueUrl.TryAdd(queueName, null); + return null; + } + } + } + + private static object ToPublishRequest(string topicArn, Message message) + { + if (string.IsNullOrEmpty(message.Header.CorrelationId)) + { + message.Header.CorrelationId = Guid.NewGuid().ToString(); + } + + var messageAttributes = new Dictionary + { + [HeaderNames.Id] = new { StringValue = message.Header.MessageId, DataType = "String" }, + [HeaderNames.Topic] = new { StringValue = topicArn, DataType = "String" }, + [HeaderNames.ContentType] = new { StringValue = message.Header.ContentType, DataType = "String" }, + [HeaderNames.HandledCount] = + new { StringValue = Convert.ToString(message.Header.HandledCount), DataType = "String" }, + [HeaderNames.MessageType] = + new { StringValue = message.Header.MessageType.ToString(), DataType = "String" }, + [HeaderNames.Timestamp] = new + { + StringValue = Convert.ToString(message.Header.TimeStamp), DataType = "String" + } + }; + + if (!string.IsNullOrEmpty(message.Header.CorrelationId)) + { + messageAttributes[HeaderNames.CorrelationId] = new + { + StringValue = Convert.ToString(message.Header.CorrelationId), DataType = "String" + }; + } + + if (!string.IsNullOrEmpty(message.Header.ReplyTo)) + { + messageAttributes.Add(HeaderNames.ReplyTo, + new { StringValue = Convert.ToString(message.Header.ReplyTo), DataType = "String" }); + } + + var bagJson = JsonSerializer.Serialize(message.Header.Bag, JsonSerialisationOptions.Options); + messageAttributes[HeaderNames.Bag] = new { StringValue = Convert.ToString(bagJson), DataType = "String" }; + + return new + { + TopicArn = topicArn, + message.Header.Subject, + Message = message.Body.Value, + MessageAttributes = messageAttributes + }; + } + + private static object ToSendMessageRequest(string queueUrl, Message message) + { + var messageAttributes = new Dictionary + { + [HeaderNames.Id] = new { StringValue = message.Header.MessageId, DataType = "String" }, + [HeaderNames.Topic] = new { StringValue = queueUrl, DataType = "String" }, + [HeaderNames.ContentType] = new { StringValue = message.Header.ContentType, DataType = "String" }, + [HeaderNames.HandledCount] = + new { StringValue = Convert.ToString(message.Header.HandledCount), DataType = "String" }, + [HeaderNames.MessageType] = + new { StringValue = message.Header.MessageType.ToString(), DataType = "String" }, + [HeaderNames.Timestamp] = new + { + StringValue = Convert.ToString(message.Header.TimeStamp), DataType = "String" + } + }; + + if (!string.IsNullOrEmpty(message.Header.ReplyTo)) + { + messageAttributes.Add(HeaderNames.ReplyTo, + new { StringValue = message.Header.ReplyTo, DataType = "String" }); + } + + if (!string.IsNullOrEmpty(message.Header.Subject)) + { + messageAttributes.Add(HeaderNames.Subject, + new { StringValue = message.Header.Subject, DataType = "String" }); + } + + if (!string.IsNullOrEmpty(message.Header.CorrelationId)) + { + messageAttributes.Add(HeaderNames.CorrelationId, + new { StringValue = message.Header.CorrelationId, DataType = "String" }); + } + + // we can set up to 10 attributes; we have set 6 above, so use a single JSON object as the bag + var bagJson = JsonSerializer.Serialize(message.Header.Bag, JsonSerialisationOptions.Options); + messageAttributes[HeaderNames.Bag] = new { StringValue = bagJson, DataType = "String" }; + return new { QueueUrl = queueUrl, MessageAttributes = messageAttributes, MessageBody = message.Body.Value }; + } + + private async Task ScheduleAsync(Message message, DateTimeOffset at, bool async, + CancellationToken cancellationToken = default) + { + await EnsureGroupExistsAsync(cancellationToken); + var id = getOrCreateSchedulerId(message); + var target = await CreateTargetAsync(id, message, async); + + using var client = factory.CreateSchedulerClient(); + try + { + await client.CreateScheduleAsync( + new CreateScheduleRequest + { + Name = id, + GroupName = schedulerGroup.Name, + Target = target, + ScheduleExpression = AtExpression(at), + ScheduleExpressionTimezone = "UTC", + State = ScheduleState.ENABLED, + ActionAfterCompletion = ActionAfterCompletion.DELETE, + FlexibleTimeWindow = scheduler.ToFlexibleTimeWindow() + }, cancellationToken); + } + catch (ConflictException) + { + if (scheduler.OnConflict == OnSchedulerConflict.Throw) + { + throw; + } + + await client.UpdateScheduleAsync( + new UpdateScheduleRequest + { + Name = id, + GroupName = schedulerGroup.Name, + Target = target, + ScheduleExpression = AtExpression(at), + ScheduleExpressionTimezone = "UTC", + State = ScheduleState.ENABLED, + ActionAfterCompletion = ActionAfterCompletion.DELETE, + FlexibleTimeWindow = scheduler.ToFlexibleTimeWindow() + }, cancellationToken); + } + + return id; + } + + private static string AtExpression(DateTimeOffset publishAt) + => $"at({publishAt.ToUniversalTime():yyyy-MM-ddTHH:mm:ss})"; + + /// + public ValueTask DisposeAsync() => new(); + + /// + public void Dispose() + { + } + + /// + public string Schedule(Message message, DateTimeOffset at) + => BrighterAsyncContext.Run(async () => await ScheduleAsync(message, at, false)); + + /// + public string Schedule(Message message, TimeSpan delay) + => Schedule(message, DateTimeOffset.UtcNow.Add(delay)); + + /// + public bool ReScheduler(string schedulerId, DateTimeOffset at) + => BrighterAsyncContext.Run(async () => await ReSchedulerAsync(schedulerId, at)); + + /// + public bool ReScheduler(string schedulerId, TimeSpan delay) + => BrighterAsyncContext.Run(async () => await ReSchedulerAsync(schedulerId, delay)); + + /// + public void Cancel(string id) + => BrighterAsyncContext.Run(async () => await CancelAsync(id)); +} diff --git a/src/Paramore.Brighter.MessageScheduler.Aws/AwsMessageSchedulerFactory.cs b/src/Paramore.Brighter.MessageScheduler.Aws/AwsMessageSchedulerFactory.cs new file mode 100644 index 0000000000..c24caade97 --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Aws/AwsMessageSchedulerFactory.cs @@ -0,0 +1,255 @@ +using System.Net; +using Amazon; +using Amazon.IdentityManagement.Model; +using Amazon.SimpleNotificationService.Model; +using Amazon.SQS.Model; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Paramore.Brighter.Tasks; + +namespace Paramore.Brighter.MessageScheduler.Aws; + +/// +/// The Aws message Scheduler factory +/// +public class AwsMessageSchedulerFactory(AWSMessagingGatewayConnection connection, string role) + : IAmAMessageSchedulerFactory +{ + private readonly SemaphoreSlim _semaphore = new(1, 1); + private string? _roleArn; + private string? _topicArn; + private string? _queueUrl; + + /// + /// The AWS Scheduler group + /// + public SchedulerGroup Group { get; set; } = new(); + + /// + /// Get or create a scheduler id + /// + public Func GetOrCreateSchedulerId { get; set; } = _ => Guid.NewGuid().ToString("N"); + + /// + /// The flexible time window + /// + public int? FlexibleTimeWindowMinutes { get; set; } + + /// + /// The topic or queue that Brighter should use for publishing/sending messaging scheduler + /// It can be Topic Name/ARN or Queue Name/Url + /// + public RoutingKey SchedulerTopicOrQueue { get; set; } = RoutingKey.Empty; + + /// + /// The AWS Role Name/ARN + /// + public string Role { get; set; } = role; + + /// + /// Allow Brighter to give a priority to as destiny topic, in case it exists. + /// + public bool UseMessageTopicAsTarget { get; set; } = true; + + /// + /// Action to be performed when a conflict happen during scheduler creating + /// + public OnSchedulerConflict OnConflict { get; set; } + + /// + /// Action to be performed when checking role + /// + public OnMissingRole MakeRole { get; set; } + + public IAmAMessageScheduler Create(IAmACommandProcessor processor) + { + var factory = new AWSClientFactory(connection); + var roleArn = GetOrCreateRoleArnAsync(factory); + if (!roleArn.IsCompleted) + { + BrighterAsyncContext.Run(async () => await roleArn); + } + + var topicArn = GetTopicArnAsync(factory); + if (!topicArn.IsCompleted) + { + BrighterAsyncContext.Run(async () => await topicArn); + } + + var queueUrl = GetQueueUrlAsync(factory); + if (!queueUrl.IsCompleted) + { + BrighterAsyncContext.Run(async () => await queueUrl); + } + + return new AwsMessageScheduler(new AWSClientFactory(connection), GetOrCreateSchedulerId, + new Scheduler + { + RoleArn = roleArn.Result, + TopicArn = topicArn.Result, + QueueUrl = queueUrl.Result, + Topic = SchedulerTopicOrQueue, + OnConflict = OnConflict, + UseMessageTopicAsTarget = UseMessageTopicAsTarget, + FlexibleTimeWindowMinutes = FlexibleTimeWindowMinutes + }, + Group); + } + + private ValueTask GetTopicArnAsync(AWSClientFactory factory) + { + if (_topicArn != null) + { + return new ValueTask(_topicArn); + } + + if (Arn.IsArn(SchedulerTopicOrQueue)) + { + _topicArn = SchedulerTopicOrQueue; + return new ValueTask(_topicArn); + } + + return new ValueTask(GetFromSnsTopicArnAsync()); + + async Task GetFromSnsTopicArnAsync() + { + using var client = factory.CreateSnsClient(); + var topic = await client.FindTopicAsync(SchedulerTopicOrQueue); + _topicArn = topic?.TopicArn ?? ""; + return _topicArn; + } + } + + private ValueTask GetQueueUrlAsync(AWSClientFactory factory) + { + { + if (_queueUrl != null) + { + return new ValueTask(_queueUrl); + } + + if (Uri.TryCreate(SchedulerTopicOrQueue, UriKind.Absolute, out _)) + { + _queueUrl = SchedulerTopicOrQueue; + return new ValueTask(_queueUrl); + } + + return new ValueTask(GetFromSqsQueueUrlAsync()); + + async Task GetFromSqsQueueUrlAsync() + { + using var client = factory.CreateSqsClient(); + try + { + var queue = await client.GetQueueUrlAsync(SchedulerTopicOrQueue); + _queueUrl = queue.QueueUrl; + } + catch (QueueDoesNotExistException) + { + _queueUrl = ""; + } + + return _queueUrl; + } + } + } + + private ValueTask GetOrCreateRoleArnAsync(AWSClientFactory factory) + { + if (_roleArn != null) + { + return new ValueTask(_roleArn); + } + + if (Arn.IsArn(Role)) + { + return new ValueTask(Role); + } + + return new ValueTask(GetOrCreateRoleArnFromIdentityAsync()); + + async Task GetOrCreateRoleArnFromIdentityAsync() + { + await _semaphore.WaitAsync(); + try + { + using var client = factory.CreateIdentityClient(); + var role = await client.GetRoleAsync(new GetRoleRequest { RoleName = Role }); + + if (role.HttpStatusCode == HttpStatusCode.OK) + { + _roleArn = role.Role.Arn; + return _roleArn; + } + + if (MakeRole == OnMissingRole.AssumeRole) + { + throw new InvalidOperationException($"Role '{Role}' not found"); + } + + return await CreateRoleArnAsync(); + } + catch (NoSuchEntityException) + { + if (MakeRole == OnMissingRole.AssumeRole) + { + throw new InvalidOperationException($"Role '{Role}' not found"); + } + + return await CreateRoleArnAsync(); + } + finally + { + _semaphore.Release(); + } + } + + async Task CreateRoleArnAsync() + { + using var client = factory.CreateIdentityClient(); + var role = await client.CreateRoleAsync(new CreateRoleRequest + { + RoleName = Role, + AssumeRolePolicyDocument = """ + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Principal": { + "Service": "scheduler.amazonaws.com" + }, + "Action": "sts:AssumeRole" + } + ] + } + """ + }); + + var policy = await client.CreatePolicyAsync(new CreatePolicyRequest + { + PolicyDocument = """ + { + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": [ + "sqs:SendMessage", + "sns:Publish" + ], + "Resource": ["*"] + }] + } + """, + }); + + await client.AttachRolePolicyAsync(new AttachRolePolicyRequest + { + RoleName = Role, PolicyArn = policy.Policy.Arn + }); + + _roleArn = role.Role.Arn; + return _roleArn; + } + } +} diff --git a/src/Paramore.Brighter.MessageScheduler.Aws/FireSchedulerMessageHandler.cs b/src/Paramore.Brighter.MessageScheduler.Aws/FireSchedulerMessageHandler.cs new file mode 100644 index 0000000000..402bc75be4 --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Aws/FireSchedulerMessageHandler.cs @@ -0,0 +1,23 @@ +using Paramore.Brighter.Scheduler.Events; + +namespace Paramore.Brighter.MessageScheduler.Aws; + +/// +/// The fire scheduler message handler +/// +public class FireSchedulerMessageHandler(IAmACommandProcessor processor) : RequestHandlerAsync +{ + public override async Task HandleAsync(FireSchedulerMessage command, CancellationToken cancellationToken = default) + { + if (command.Async) + { + await processor.PostAsync(command, cancellationToken: cancellationToken); + } + else + { + processor.Post(command); + } + + return await base.HandleAsync(command, cancellationToken); + } +} diff --git a/src/Paramore.Brighter.MessageScheduler.Aws/FireSchedulerMessageMapper.cs b/src/Paramore.Brighter.MessageScheduler.Aws/FireSchedulerMessageMapper.cs new file mode 100644 index 0000000000..4d309a9d10 --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Aws/FireSchedulerMessageMapper.cs @@ -0,0 +1,21 @@ +using System.Text.Json; +using Paramore.Brighter.Scheduler.Events; + +namespace Paramore.Brighter.MessageScheduler.Aws; + +/// +/// The fired scheduler message. +/// +public class FireSchedulerMessageMapper : IAmAMessageMapper +{ + public IRequestContext? Context { get; set; } + public Message MapToMessage(FireSchedulerMessage request, Publication publication) + { + throw new NotImplementedException(); + } + + public FireSchedulerMessage MapToRequest(Message message) + { + return JsonSerializer.Deserialize(message.Body.Value, JsonSerialisationOptions.Options)!; + } +} diff --git a/src/Paramore.Brighter.MessageScheduler.Aws/MessageSchedulerTarget.cs b/src/Paramore.Brighter.MessageScheduler.Aws/MessageSchedulerTarget.cs new file mode 100644 index 0000000000..c5e137cb6a --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Aws/MessageSchedulerTarget.cs @@ -0,0 +1,25 @@ +using Paramore.Brighter.Scheduler.Events; + +namespace Paramore.Brighter.MessageScheduler.Aws; + +/// +/// The source trigger for fired scheduler message +/// +public enum MessageSchedulerTarget +{ + /// + /// The AWS scheduler will scheduler the on Sqs + /// + /// + /// For this case is necessary to configure a subscription to + /// + Sqs, + + /// + /// The AWS scheduler will scheduler the on SNS + /// + /// + /// For this case is necessary to configure a subscription to + /// + Sns +} diff --git a/src/Paramore.Brighter.MessageScheduler.Aws/OnMissingRole.cs b/src/Paramore.Brighter.MessageScheduler.Aws/OnMissingRole.cs new file mode 100644 index 0000000000..5cb2f5efff --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Aws/OnMissingRole.cs @@ -0,0 +1,17 @@ +namespace Paramore.Brighter.MessageScheduler.Aws; + +/// +/// Action to be performed when checking role +/// +public enum OnMissingRole +{ + /// + /// Assume the role if it exists + /// + AssumeRole, + /// + /// Create the role if it does not exist + /// + CreateRole +} + diff --git a/src/Paramore.Brighter.MessageScheduler.Aws/OnMissingSchedulerGroup.cs b/src/Paramore.Brighter.MessageScheduler.Aws/OnMissingSchedulerGroup.cs new file mode 100644 index 0000000000..0cfe29ce2e --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Aws/OnMissingSchedulerGroup.cs @@ -0,0 +1,17 @@ +namespace Paramore.Brighter.MessageScheduler.Aws; + +/// +/// Action to be performed about scheduler group +/// +public enum OnMissingSchedulerGroup +{ + /// + /// Assume teh message group exists + /// + Assume, + + /// + /// Check if the message group exists, if not create + /// + Create +} diff --git a/src/Paramore.Brighter.MessageScheduler.Aws/Paramore.Brighter.MessageScheduler.Aws.csproj b/src/Paramore.Brighter.MessageScheduler.Aws/Paramore.Brighter.MessageScheduler.Aws.csproj new file mode 100644 index 0000000000..a0651b396d --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Aws/Paramore.Brighter.MessageScheduler.Aws.csproj @@ -0,0 +1,19 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + + diff --git a/src/Paramore.Brighter.MessageScheduler.Aws/Scheduler.cs b/src/Paramore.Brighter.MessageScheduler.Aws/Scheduler.cs new file mode 100644 index 0000000000..70c872f951 --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Aws/Scheduler.cs @@ -0,0 +1,38 @@ +using Amazon.Scheduler; +using Amazon.Scheduler.Model; + +namespace Paramore.Brighter.MessageScheduler.Aws; + +/// +/// The AWS scheduler attributes. +/// +public class Scheduler +{ + /// + /// The role ARN + /// + public string RoleArn { get; init; } = string.Empty; + + /// + /// The flexible time window + /// + public int? FlexibleTimeWindowMinutes { get; init; } + + public RoutingKey Topic { get; set; } = RoutingKey.Empty; + + public string TopicArn { get; init; } = string.Empty; + public string QueueUrl { get; init; } = string.Empty; + + public bool UseMessageTopicAsTarget { get; set; } + + public OnSchedulerConflict OnConflict { get; init; } + + internal FlexibleTimeWindow ToFlexibleTimeWindow() + { + return new FlexibleTimeWindow + { + Mode = FlexibleTimeWindowMinutes == null ? FlexibleTimeWindowMode.OFF : FlexibleTimeWindowMode.FLEXIBLE, + MaximumWindowInMinutes = FlexibleTimeWindowMinutes + }; + } +} diff --git a/src/Paramore.Brighter.MessageScheduler.Aws/SchedulerGroup.cs b/src/Paramore.Brighter.MessageScheduler.Aws/SchedulerGroup.cs new file mode 100644 index 0000000000..d9c629dd97 --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Aws/SchedulerGroup.cs @@ -0,0 +1,24 @@ +using Amazon.Scheduler.Model; + +namespace Paramore.Brighter.MessageScheduler.Aws; + +/// +/// The AWS Scheduler group attributes +/// +public class SchedulerGroup +{ + /// + /// The AWS scheduler group. + /// + public string Name { get; set; } = "default"; + + /// + /// The AWS scheduler group tags + /// + public List Tags { get; set; } = [new() {Key = "Source", Value = "Brighter"}]; + + /// + /// The action to be performed during scheduler group creation + /// + public OnMissingSchedulerGroup MakeSchedulerGroup { get; set; } +} diff --git a/src/Paramore.Brighter.MessageScheduler.Quartz/Paramore.Brighter.MessageScheduler.Quartz.csproj b/src/Paramore.Brighter.MessageScheduler.Quartz/Paramore.Brighter.MessageScheduler.Quartz.csproj new file mode 100644 index 0000000000..d10d609301 --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Quartz/Paramore.Brighter.MessageScheduler.Quartz.csproj @@ -0,0 +1,17 @@ + + + + netstandard2.0;net8.0;net9.0 + enable + enable + + + + + + + + + + + diff --git a/src/Paramore.Brighter.MessageScheduler.Quartz/QuartzBrighterJob.cs b/src/Paramore.Brighter.MessageScheduler.Quartz/QuartzBrighterJob.cs new file mode 100644 index 0000000000..30ccf253b9 --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Quartz/QuartzBrighterJob.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using Paramore.Brighter.Scheduler.Events; +using Quartz; + +namespace Paramore.Brighter.MessageScheduler.Quartz; + +/// +/// The Quartz Message scheduler Job +/// +/// +public class QuartzBrighterJob(IAmACommandProcessor processor) : IJob +{ + public async Task Execute(IJobExecutionContext context) + { + if (!context.JobDetail.JobDataMap.TryGetString("message", out var obj)) + { + throw new InvalidOperationException("Not message, something is wrong with this job scheduler"); + } + + var fireScheduler = JsonSerializer.Deserialize(obj!, JsonSerialisationOptions.Options)!; + if (fireScheduler.Async) + { + await processor.PostAsync(fireScheduler); + } + else + { + processor.Post(fireScheduler); + } + } +} diff --git a/src/Paramore.Brighter.MessageScheduler.Quartz/QuartzMessageScheduler.cs b/src/Paramore.Brighter.MessageScheduler.Quartz/QuartzMessageScheduler.cs new file mode 100644 index 0000000000..447771f5f7 --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Quartz/QuartzMessageScheduler.cs @@ -0,0 +1,108 @@ +using Paramore.Brighter.Scheduler.Events; +using Paramore.Brighter.Tasks; +using Quartz; +using JsonSerializer = System.Text.Json.JsonSerializer; + +namespace Paramore.Brighter.MessageScheduler.Quartz; + +/// +/// The Quartz Message scheduler +/// +/// +/// +/// +public class QuartzMessageScheduler(IScheduler scheduler, string? group, Func getOrCreateSchedulerId) + : IAmAMessageSchedulerSync, IAmAMessageSchedulerAsync +{ + /// + public string Schedule(Message message, DateTimeOffset at) + { + var id = getOrCreateSchedulerId(message); + var job = JobBuilder.Create() + .WithIdentity(getOrCreateSchedulerId(message), group!) + .UsingJobData("message", JsonSerializer.Serialize( + new FireSchedulerMessage { Id = id, Async = false, Message = message }, + JsonSerialisationOptions.Options)) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity(getOrCreateSchedulerId(message) + "-trigger", group!) + .StartAt(at) + .Build(); + + var tmp = BrighterAsyncContext.Run(async () => await scheduler.ScheduleJob(job, trigger)); + return id; + } + + /// + public string Schedule(Message message, TimeSpan delay) => Schedule(message, DateTimeOffset.Now.Add(delay)); + + /// + public bool ReScheduler(string schedulerId, DateTimeOffset at) + => BrighterAsyncContext.Run(async () => await ReSchedulerAsync(schedulerId, at)); + + /// + public bool ReScheduler(string schedulerId, TimeSpan delay) => + ReScheduler(schedulerId, DateTimeOffset.Now.Add(delay)); + + /// + public void Cancel(string id) + => BrighterAsyncContext.Run(async () => await CancelAsync(id)); + + /// + public async Task ScheduleAsync(Message message, DateTimeOffset at, + CancellationToken cancellationToken = default) + { + var id = getOrCreateSchedulerId(message); + var job = JobBuilder.Create() + .WithIdentity(getOrCreateSchedulerId(message), group!) + .UsingJobData("message", JsonSerializer.Serialize( + new FireSchedulerMessage { Id = id, Async = false, Message = message }, + JsonSerialisationOptions.Options)) + .Build(); + + var trigger = TriggerBuilder.Create() + .WithIdentity(getOrCreateSchedulerId(message) + "-trigger", group!) + .StartAt(at) + .Build(); + + await scheduler.ScheduleJob(job, trigger, cancellationToken); + return id; + } + + /// + public async Task ScheduleAsync(Message message, TimeSpan delay, + CancellationToken cancellationToken = default) + => await ScheduleAsync(message, DateTimeOffset.Now.Add(delay), cancellationToken); + + /// + public async Task ReSchedulerAsync(string schedulerId, DateTimeOffset at, + CancellationToken cancellationToken = default) + { + var date = await scheduler.RescheduleJob(new TriggerKey(schedulerId + "-trigger", group!), TriggerBuilder + .Create() + .WithIdentity(schedulerId + "-trigger", group!) + .StartAt(at) + .Build(), cancellationToken); + + return date != null; + } + + /// + public async Task ReSchedulerAsync(string schedulerId, TimeSpan delay, + CancellationToken cancellationToken = default) => + await ReSchedulerAsync(schedulerId, DateTimeOffset.Now.Add(delay), cancellationToken); + + /// + public async Task CancelAsync(string id, CancellationToken cancellationToken = default) + => await scheduler.DeleteJob(new JobKey(id, group!), cancellationToken); + + + /// + public ValueTask DisposeAsync() => new(); + + /// + public void Dispose() + { + } +} diff --git a/src/Paramore.Brighter.MessageScheduler.Quartz/QuartzMessageSchedulerFactory.cs b/src/Paramore.Brighter.MessageScheduler.Quartz/QuartzMessageSchedulerFactory.cs new file mode 100644 index 0000000000..db292ef22e --- /dev/null +++ b/src/Paramore.Brighter.MessageScheduler.Quartz/QuartzMessageSchedulerFactory.cs @@ -0,0 +1,23 @@ +using Quartz; + +namespace Paramore.Brighter.MessageScheduler.Quartz; + +/// +/// The factory +/// +public class QuartzMessageSchedulerFactory(IScheduler scheduler) : IAmAMessageSchedulerFactory +{ + /// + /// The Quartz Group. + /// + public string? Group { get; set; } + + /// + /// Get or create scheduler + /// + public Func GetOrCreateSchedulerId { get; set; } = message => message.Id; + + /// + public IAmAMessageScheduler Create(IAmACommandProcessor processor) + => new QuartzMessageScheduler(scheduler, Group, GetOrCreateSchedulerId); +} diff --git a/src/Paramore.Brighter.MessagingGateway.AWSSQS/AWSClientFactory.cs b/src/Paramore.Brighter.MessagingGateway.AWSSQS/AWSClientFactory.cs index a02f17fa30..11e89c1da7 100644 --- a/src/Paramore.Brighter.MessagingGateway.AWSSQS/AWSClientFactory.cs +++ b/src/Paramore.Brighter.MessagingGateway.AWSSQS/AWSClientFactory.cs @@ -32,40 +32,63 @@ THE SOFTWARE. */ namespace Paramore.Brighter.MessagingGateway.AWSSQS; -internal class AWSClientFactory( +/// +/// The Aws Client factory +/// +/// +/// +/// +public class AWSClientFactory( AWSCredentials credentials, RegionEndpoint region, Action? clientConfigAction) { + public AWSCredentials Credentials => credentials; + + public RegionEndpoint RegionEndpoint => region; + public Action? ClientConfigAction => clientConfigAction; + public AWSClientFactory(AWSMessagingGatewayConnection connection) : this(connection.Credentials, connection.Region, connection.ClientConfigAction) { } + /// + /// Create SNS Client + /// + /// New instance . public AmazonSimpleNotificationServiceClient CreateSnsClient() { - var config = new AmazonSimpleNotificationServiceConfig { RegionEndpoint = region }; + var config = new AmazonSimpleNotificationServiceConfig { RegionEndpoint = RegionEndpoint }; - clientConfigAction?.Invoke(config); + ClientConfigAction?.Invoke(config); - return new AmazonSimpleNotificationServiceClient(credentials, config); + return new AmazonSimpleNotificationServiceClient(Credentials, config); } + /// + /// Create SQS Client + /// + /// New instance . public AmazonSQSClient CreateSqsClient() { - var config = new AmazonSQSConfig { RegionEndpoint = region }; + var config = new AmazonSQSConfig { RegionEndpoint = RegionEndpoint }; - clientConfigAction?.Invoke(config); + ClientConfigAction?.Invoke(config); - return new AmazonSQSClient(credentials, config); + return new AmazonSQSClient(Credentials, config); } + /// + /// Create STS Client + /// + /// New instance . public AmazonSecurityTokenServiceClient CreateStsClient() { - var config = new AmazonSecurityTokenServiceConfig { RegionEndpoint = region }; + var config = new AmazonSecurityTokenServiceConfig { RegionEndpoint = RegionEndpoint }; - clientConfigAction?.Invoke(config); + ClientConfigAction?.Invoke(config); - return new AmazonSecurityTokenServiceClient(credentials, config); + return new AmazonSecurityTokenServiceClient(Credentials, config); } } diff --git a/src/Paramore.Brighter.MessagingGateway.AWSSQS/SnsMessageProducer.cs b/src/Paramore.Brighter.MessagingGateway.AWSSQS/SnsMessageProducer.cs index 1a8c222882..c1e68c182e 100644 --- a/src/Paramore.Brighter.MessagingGateway.AWSSQS/SnsMessageProducer.cs +++ b/src/Paramore.Brighter.MessagingGateway.AWSSQS/SnsMessageProducer.cs @@ -50,6 +50,9 @@ public class SnsMessageProducer : AWSMessagingGateway, IAmAMessageProducerSync, /// public Activity? Span { get; set; } + /// + public IAmAMessageScheduler? Scheduler { get; set; } + /// /// Initializes a new instance of the class. /// @@ -119,38 +122,15 @@ public async Task ConfirmTopicExistsAsync(string? topic = null, /// /// The message. /// Allows cancellation of the Send operation - public async Task SendAsync(Message message, CancellationToken cancellationToken = default) - { - s_logger.LogDebug( - "SNSMessageProducer: Publishing message with topic {Topic} and id {Id} and message: {Request}", - message.Header.Topic, message.Id, message.Body); - - await ConfirmTopicExistsAsync(message.Header.Topic, cancellationToken); - - if (string.IsNullOrEmpty(ChannelAddress)) - throw new InvalidOperationException( - $"Failed to publish message with topic {message.Header.Topic} and id {message.Id} and message: {message.Body} as the topic does not exist"); - - using var client = _clientFactory.CreateSnsClient(); - var publisher = new SnsMessagePublisher(ChannelAddress!, client, - _publication.SnsAttributes?.Type ?? SnsSqsType.Standard); - var messageId = await publisher.PublishAsync(message); - - if (messageId == null) - throw new InvalidOperationException( - $"Failed to publish message with topic {message.Header.Topic} and id {message.Id} and message: {message.Body}"); - - s_logger.LogDebug( - "SNSMessageProducer: Published message with topic {Topic}, Brighter messageId {MessageId} and SNS messageId {SNSMessageId}", - message.Header.Topic, message.Id, messageId); - } + public async Task SendAsync(Message message, CancellationToken cancellationToken = default) + => await SendWithDelayAsync(message, TimeSpan.Zero, cancellationToken); /// /// Sends the specified message. /// Sync over Async /// /// The message. - public void Send(Message message) => BrighterAsyncContext.Run(async () => await SendAsync(message)); + public void Send(Message message) => SendWithDelay(message, TimeSpan.Zero); /// /// Sends the specified message, with a delay. @@ -159,10 +139,7 @@ public async Task SendAsync(Message message, CancellationToken cancellationToken /// The sending delay /// Task. public void SendWithDelay(Message message, TimeSpan? delay = null) - { - // SNS doesn't support publish with delay - Send(message); - } + => BrighterAsyncContext.Run(async () => await SendWithDelayAsync(message, TimeSpan.Zero)); /// /// Sends the specified message, with a delay @@ -173,8 +150,46 @@ public void SendWithDelay(Message message, TimeSpan? delay = null) /// public async Task SendWithDelayAsync(Message message, TimeSpan? delay, CancellationToken cancellationToken = default) - { - // SNS doesn't support publish with delay - await SendAsync(message, cancellationToken); + { + delay ??= TimeSpan.Zero; + if (delay != TimeSpan.Zero) + { + if (Scheduler is IAmAMessageSchedulerAsync async) + { + await async.ScheduleAsync(message, delay.Value, cancellationToken); + return; + } + + if (Scheduler is IAmAMessageSchedulerSync sync) + { + sync.Schedule(message, delay.Value); + return; + } + + s_logger.LogWarning("SNSMessageProducer: no scheduler configured, message will be sent immediately"); + } + + s_logger.LogDebug( + "SNSMessageProducer: Publishing message with topic {Topic} and id {Id} and message: {Request}", + message.Header.Topic, message.Id, message.Body); + + await ConfirmTopicExistsAsync(message.Header.Topic, cancellationToken); + + if (string.IsNullOrEmpty(ChannelAddress)) + throw new InvalidOperationException( + $"Failed to publish message with topic {message.Header.Topic} and id {message.Id} and message: {message.Body} as the topic does not exist"); + + using var client = _clientFactory.CreateSnsClient(); + var publisher = new SnsMessagePublisher(ChannelAddress!, client, + _publication.SnsAttributes?.Type ?? SnsSqsType.Standard); + var messageId = await publisher.PublishAsync(message); + + if (messageId == null) + throw new InvalidOperationException( + $"Failed to publish message with topic {message.Header.Topic} and id {message.Id} and message: {message.Body}"); + + s_logger.LogDebug( + "SNSMessageProducer: Published message with topic {Topic}, Brighter messageId {MessageId} and SNS messageId {SNSMessageId}", + message.Header.Topic, message.Id, messageId); } } diff --git a/src/Paramore.Brighter.MessagingGateway.AWSSQS/SnsMessagePublisher.cs b/src/Paramore.Brighter.MessagingGateway.AWSSQS/SnsMessagePublisher.cs index 8bda62e18f..9a1d89b221 100644 --- a/src/Paramore.Brighter.MessagingGateway.AWSSQS/SnsMessagePublisher.cs +++ b/src/Paramore.Brighter.MessagingGateway.AWSSQS/SnsMessagePublisher.cs @@ -50,6 +50,11 @@ public SnsMessagePublisher(string topicArn, AmazonSimpleNotificationServiceClien var messageString = message.Body.Value; var publishRequest = new PublishRequest(_topicArn, messageString, message.Header.Subject); + if (string.IsNullOrEmpty(message.Header.CorrelationId)) + { + message.Header.CorrelationId = Guid.NewGuid().ToString(); + } + var messageAttributes = new Dictionary { [HeaderNames.Id] = diff --git a/src/Paramore.Brighter.MessagingGateway.AWSSQS/SqsMessageProducer.cs b/src/Paramore.Brighter.MessagingGateway.AWSSQS/SqsMessageProducer.cs index 63bee0c50d..50ecaad896 100644 --- a/src/Paramore.Brighter.MessagingGateway.AWSSQS/SqsMessageProducer.cs +++ b/src/Paramore.Brighter.MessagingGateway.AWSSQS/SqsMessageProducer.cs @@ -50,6 +50,9 @@ public class SqsMessageProducer : AWSMessagingGateway, IAmAMessageProducerAsync, /// public Activity? Span { get; set; } + /// + public IAmAMessageScheduler? Scheduler { get; set; } + /// /// Initialize a new instance of the . /// @@ -131,11 +134,29 @@ public async Task ConfirmQueueExistsAsync(string? queue = null, Cancellati } public async Task SendAsync(Message message, CancellationToken cancellationToken = default) - => await SendWithDelayAsync(message, null, cancellationToken); + => await SendWithDelayAsync(message, TimeSpan.Zero, cancellationToken); public async Task SendWithDelayAsync(Message message, TimeSpan? delay, CancellationToken cancellationToken = default) { + delay ??= TimeSpan.Zero; + if (delay > TimeSpan.FromMinutes(15)) + { + if (Scheduler is IAmAMessageSchedulerAsync async) + { + await async.ScheduleAsync(message, delay.Value, cancellationToken); + return; + } + + if (Scheduler is IAmAMessageSchedulerSync sync) + { + sync.Schedule(message, delay.Value); + return; + } + + s_logger.LogWarning("SQSMessageProducer: no scheduler configured, message will be sent immediately"); + } + s_logger.LogDebug( "SQSMessageProducer: Publishing message with topic {Topic} and id {Id} and message: {Request}", message.Header.Topic, message.Id, message.Body); diff --git a/src/Paramore.Brighter.MessagingGateway.AzureServiceBus/AzureServiceBusMessageProducer.cs b/src/Paramore.Brighter.MessagingGateway.AzureServiceBus/AzureServiceBusMessageProducer.cs index 1f2c4a3a2e..d1c56a7ca2 100644 --- a/src/Paramore.Brighter.MessagingGateway.AzureServiceBus/AzureServiceBusMessageProducer.cs +++ b/src/Paramore.Brighter.MessagingGateway.AzureServiceBus/AzureServiceBusMessageProducer.cs @@ -65,6 +65,9 @@ public abstract class AzureServiceBusMessageProducer : IAmAMessageProducerSync, /// public Activity? Span { get; set; } + /// + public IAmAMessageScheduler? Scheduler { get; set; } + /// /// An Azure Service Bus Message producer /// diff --git a/src/Paramore.Brighter.MessagingGateway.Kafka/KafkaMessageProducer.cs b/src/Paramore.Brighter.MessagingGateway.Kafka/KafkaMessageProducer.cs index 65c8b89078..f7f6c8bfe8 100644 --- a/src/Paramore.Brighter.MessagingGateway.Kafka/KafkaMessageProducer.cs +++ b/src/Paramore.Brighter.MessagingGateway.Kafka/KafkaMessageProducer.cs @@ -27,6 +27,7 @@ THE SOFTWARE. */ using System.Threading.Tasks; using Confluent.Kafka; using Microsoft.Extensions.Logging; +using Paramore.Brighter.Tasks; namespace Paramore.Brighter.MessagingGateway.Kafka { @@ -48,6 +49,9 @@ public class KafkaMessageProducer : KafkaMessagingGateway, IAmAMessageProducerSy /// public Activity Span { get; set; } + /// + public IAmAMessageScheduler Scheduler { get; set; } + private IProducer _producer; private readonly IKafkaMessageHeaderBuilder _headerBuilder; private readonly ProducerConfig _producerConfig; @@ -122,7 +126,6 @@ public KafkaMessageProducer( /// /// Dispose of the producer /// - /// Are we disposing or being called by the GC public void Dispose() { Dispose(true); @@ -133,7 +136,6 @@ public void Dispose() /// /// Dispose of the producer /// - /// Are we disposing or being called by the GC public ValueTask DisposeAsync() { Dispose(true); @@ -192,12 +194,62 @@ public void Init() /// The Kafka client has entered an unrecoverable state public void Send(Message message) { + SendWithDelay(message, TimeSpan.Zero); + } + + /// + /// Sends the specified message. + /// + /// + /// Usage of the Kafka async producer is much slower than the sync producer. This is because the async producer + /// produces a single message and waits for the result before producing the next message. By contrast the synchronous + /// producer queues work and uses a dedicated thread to dispatch + /// + /// The message. + /// Allows cancellation of the in-flight send operation + public async Task SendAsync(Message message, CancellationToken cancellationToken = default) + { + await SendWithDelayAsync(message, TimeSpan.Zero, cancellationToken); + } + + /// + /// Sends the message with the given delay + /// + /// + /// No delay support implemented + /// + /// The message to send + /// The delay to use + public void SendWithDelay(Message message, TimeSpan? delay = null) + { + delay ??= TimeSpan.Zero; + if (delay != TimeSpan.Zero) + { + if (Scheduler is IAmAMessageSchedulerSync sync) + { + sync.Schedule(message, delay.Value); + return; + } + + if (Scheduler is IAmAMessageSchedulerAsync async) + { + BrighterAsyncContext.Run(async () => await async.ScheduleAsync(message, delay.Value)); + return; + } + + s_logger.LogWarning("KafkaMessageProducer: no scheduler configured, message will be sent immediately"); + } + if (message == null) + { throw new ArgumentNullException(nameof(message)); + } if (_hasFatalProducerError) + { throw new ChannelFailureException($"Producer is in unrecoverable state"); - + } + try { s_logger.LogDebug( @@ -206,9 +258,9 @@ public void Send(Message message) message.Header.Topic, message.Body.Value ); - + _publisher.PublishMessage(message, report => PublishResults(report.Status, report.Headers)); - + } catch (ProduceException pe) { @@ -227,7 +279,7 @@ public void Send(Message message) ioe.Message ); throw new ChannelFailureException("Error talking to the broker, see inner exception for details", ioe); - + } catch (ArgumentException ae) { @@ -237,106 +289,99 @@ public void Send(Message message) ae.Message ); throw new ChannelFailureException("Error talking to the broker, see inner exception for details", ae); - + } catch (KafkaException kafkaException) { - s_logger.LogError(kafkaException, $"KafkaMessageProducer: There was an error sending to topic {Topic})"); - + s_logger.LogError(kafkaException, "KafkaMessageProducer: There was an error sending to topic {Topic})", Topic); + if (kafkaException.Error.IsFatal) //this can't be recovered and requires a new producer throw; - + throw new ChannelFailureException("Error connecting to Kafka, see inner exception for details", kafkaException); } } /// - /// Sends the specified message. + /// Sends the message with the given delay /// /// - /// Usage of the Kafka async producer is much slower than the sync producer. This is because the async producer - /// produces a single message and waits for the result before producing the next message. By contrast the synchronous - /// producer queues work and uses a dedicated thread to dispatch + /// No delay support implemented /// - /// The message. - /// Allows cancellation of the in-flight send operation - public async Task SendAsync(Message message, CancellationToken cancellationToken = default) + /// The message to send + /// The delay to use + /// Cancels the send operation + public async Task SendWithDelayAsync(Message message, TimeSpan? delay, CancellationToken cancellationToken = default) { - if (message == null) - throw new ArgumentNullException(nameof(message)); - - if (_hasFatalProducerError) - throw new ChannelFailureException($"Producer is in unrecoverable state"); - try - { - s_logger.LogDebug( - "Sending message to Kafka. Servers {Servers} Topic: {Topic} Body: {Request}", - _producerConfig.BootstrapServers, - message.Header.Topic, - message.Body.Value - ); - - await _publisher.PublishMessageAsync(message, result => PublishResults(result.Status, result.Headers), cancellationToken); - - } - catch (ProduceException pe) - { - s_logger.LogError(pe, - "Error sending message to Kafka servers {Servers} because {ErrorMessage} ", - _producerConfig.BootstrapServers, - pe.Error.Reason - ); - throw new ChannelFailureException("Error talking to the broker, see inner exception for details", pe); - } - catch (InvalidOperationException ioe) - { - s_logger.LogError(ioe, - "Error sending message to Kafka servers {Servers} because {ErrorMessage} ", - _producerConfig.BootstrapServers, - ioe.Message - ); - throw new ChannelFailureException("Error talking to the broker, see inner exception for details", ioe); + delay ??= TimeSpan.Zero; + if (delay != TimeSpan.Zero) + { + if (Scheduler is IAmAMessageSchedulerAsync async) + { + await async.ScheduleAsync(message, delay.Value, cancellationToken); + return; + } + + if (Scheduler is IAmAMessageSchedulerSync sync) + { + sync.Schedule(message, delay.Value); + return; + } + + s_logger.LogWarning("KafkaMessageProducer: no scheduler configured, message will be sent immediately"); + } + + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } - } - catch (ArgumentException ae) - { + if (_hasFatalProducerError) + { + throw new ChannelFailureException("Producer is in unrecoverable state"); + } + + try + { + s_logger.LogDebug( + "Sending message to Kafka. Servers {Servers} Topic: {Topic} Body: {Request}", + _producerConfig.BootstrapServers, + message.Header.Topic, + message.Body.Value + ); + + await _publisher.PublishMessageAsync(message, result => PublishResults(result.Status, result.Headers), cancellationToken); + + } + catch (ProduceException pe) + { + s_logger.LogError(pe, + "Error sending message to Kafka servers {Servers} because {ErrorMessage} ", + _producerConfig.BootstrapServers, + pe.Error.Reason + ); + throw new ChannelFailureException("Error talking to the broker, see inner exception for details", pe); + } + catch (InvalidOperationException ioe) + { + s_logger.LogError(ioe, + "Error sending message to Kafka servers {Servers} because {ErrorMessage} ", + _producerConfig.BootstrapServers, + ioe.Message + ); + throw new ChannelFailureException("Error talking to the broker, see inner exception for details", ioe); + + } + catch (ArgumentException ae) + { s_logger.LogError(ae, "Error sending message to Kafka servers {Servers} because {ErrorMessage} ", _producerConfig.BootstrapServers, ae.Message ); throw new ChannelFailureException("Error talking to the broker, see inner exception for details", ae); - - } - } - - /// - /// Sends the message with the given delay - /// - /// - /// No delay support implemented - /// - /// The message to send - /// The delay to use - public void SendWithDelay(Message message, TimeSpan? delay = null) - { - //TODO: No delay support implemented - Send(message); - } - - /// - /// Sends the message with the given delay - /// - /// - /// No delay support implemented - /// - /// The message to send - /// The delay to use - /// Cancels the send operation - public async Task SendWithDelayAsync(Message message, TimeSpan? delay, CancellationToken cancellationToken = default) - { - //TODO: No delay support implemented - await SendAsync(message); + + } } private void Dispose(bool disposing) diff --git a/src/Paramore.Brighter.MessagingGateway.MQTT/MQTTMessageProducer.cs b/src/Paramore.Brighter.MessagingGateway.MQTT/MQTTMessageProducer.cs index f9ae1a9a79..acbf9472ca 100644 --- a/src/Paramore.Brighter.MessagingGateway.MQTT/MQTTMessageProducer.cs +++ b/src/Paramore.Brighter.MessagingGateway.MQTT/MQTTMessageProducer.cs @@ -3,6 +3,9 @@ using System.Diagnostics; using System.Threading; using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter.Logging; +using Paramore.Brighter.Tasks; namespace Paramore.Brighter.MessagingGateway.MQTT { @@ -13,13 +16,20 @@ namespace Paramore.Brighter.MessagingGateway.MQTT /// public class MQTTMessageProducer : IAmAMessageProducer, IAmAMessageProducerAsync, IAmAMessageProducerSync { + private static readonly ILogger s_logger = ApplicationLogging.CreateLogger(); + public int MaxOutStandingMessages { get; set; } = -1; public int MaxOutStandingCheckIntervalMilliSeconds { get; set; } = 0; public Dictionary OutBoxBag { get; set; } = new Dictionary(); + /// public Publication Publication { get; set; } + /// public Activity Span { get; set; } + + /// + public IAmAMessageScheduler Scheduler { get; set; } private MQTTMessagePublisher _mqttMessagePublisher; @@ -52,10 +62,7 @@ public ValueTask DisposeAsync() /// The message. public void Send(Message message) { - if (message == null) - throw new ArgumentNullException(nameof(message)); - - _mqttMessagePublisher.PublishMessage(message); + SendWithDelay(message, TimeSpan.Zero); } /// @@ -66,10 +73,7 @@ public void Send(Message message) /// Task. public async Task SendAsync(Message message, CancellationToken cancellationToken = default) { - if (message == null) - throw new ArgumentNullException(nameof(message)); - - await _mqttMessagePublisher.PublishMessageAsync(message, cancellationToken); + await SendWithDelayAsync(message, TimeSpan.Zero, cancellationToken); } /// @@ -79,8 +83,31 @@ public async Task SendAsync(Message message, CancellationToken cancellationToken /// Delay is not natively supported - don't block with Task.Delay public void SendWithDelay(Message message, TimeSpan? delay = null) { + delay ??= TimeSpan.Zero; + if (delay != TimeSpan.Zero) + { + if (Scheduler is IAmAMessageSchedulerSync sync) + { + sync.Schedule(message, delay.Value); + return; + } + + if (Scheduler is IAmAMessageSchedulerAsync async) + { + BrighterAsyncContext.Run(async () => await async.ScheduleAsync(message, delay.Value)); + return; + } + + s_logger.LogWarning("MQTTMessageProducer: no scheduler configured, message will be sent immediately"); + } + // delay is not natively supported - Send(message); + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + _mqttMessagePublisher.PublishMessage(message); } /// @@ -91,10 +118,30 @@ public void SendWithDelay(Message message, TimeSpan? delay = null) /// Allows cancellation of the Send operation public async Task SendWithDelayAsync(Message message, TimeSpan? delay, CancellationToken cancellationToken = default) { - // delay is not natively supported - await SendAsync(message, cancellationToken); - } + delay ??= TimeSpan.Zero; + if (delay != TimeSpan.Zero) + { + if (Scheduler is IAmAMessageSchedulerAsync async) + { + await async.ScheduleAsync(message, delay.Value, cancellationToken); + return; + } - + if (Scheduler is IAmAMessageSchedulerSync sync) + { + sync.Schedule(message, delay.Value); + return; + } + + s_logger.LogWarning("MQTTMessageProducer: no scheduler configured, message will be sent immediately"); + } + + if (message == null) + { + throw new ArgumentNullException(nameof(message)); + } + + await _mqttMessagePublisher.PublishMessageAsync(message, cancellationToken); + } } } diff --git a/src/Paramore.Brighter.MessagingGateway.MsSql/MsSqlMessageProducer.cs b/src/Paramore.Brighter.MessagingGateway.MsSql/MsSqlMessageProducer.cs index 4382c57c9e..b251e1ae08 100644 --- a/src/Paramore.Brighter.MessagingGateway.MsSql/MsSqlMessageProducer.cs +++ b/src/Paramore.Brighter.MessagingGateway.MsSql/MsSqlMessageProducer.cs @@ -1,4 +1,5 @@ #region Licence + /* The MIT License (MIT) Copyright © 2022 Ian Cooper @@ -19,6 +20,7 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + #endregion using System; @@ -29,6 +31,7 @@ THE SOFTWARE. */ using Paramore.Brighter.Logging; using Paramore.Brighter.MessagingGateway.MsSql.SqlQueues; using Paramore.Brighter.MsSql; +using Paramore.Brighter.Tasks; namespace Paramore.Brighter.MessagingGateway.MsSql { @@ -50,6 +53,9 @@ public class MsSqlMessageProducer : IAmAMessageProducerSync, IAmAMessageProducer /// public Activity? Span { get; set; } + /// + public IAmAMessageScheduler? Scheduler { get; set; } + /// /// Initializes a new instance of the class. /// @@ -84,11 +90,7 @@ public MsSqlMessageProducer( /// The message to send. public void Send(Message message) { - var topic = message.Header.Topic; - - s_logger.LogDebug("MsSqlMessageProducer: send message with topic {Topic} and id {Id}", topic, message.Id); - - _sqlQ.Send(message, topic); + SendWithDelay(message, TimeSpan.Zero); } /// @@ -99,12 +101,7 @@ public void Send(Message message) /// A task that represents the asynchronous send operation. public async Task SendAsync(Message message, CancellationToken cancellationToken = default) { - var topic = message.Header.Topic; - - s_logger.LogDebug( - "MsSqlMessageProducer: send async message with topic {Topic} and id {Id}", topic, message.Id); - - await _sqlQ.SendAsync(message, topic, TimeSpan.Zero); + await SendWithDelayAsync(message, TimeSpan.Zero, cancellationToken); } /// @@ -114,9 +111,30 @@ public async Task SendAsync(Message message, CancellationToken cancellationToken /// The message to send. /// The delay to use. public void SendWithDelay(Message message, TimeSpan? delay = null) - { - // No delay support implemented - Send(message); + { + delay ??= TimeSpan.Zero; + if (delay != TimeSpan.Zero) + { + if (Scheduler is IAmAMessageSchedulerSync sync) + { + sync.Schedule(message, delay.Value); + return; + } + + if (Scheduler is IAmAMessageSchedulerAsync async) + { + BrighterAsyncContext.Run(async () => await async.ScheduleAsync(message, delay.Value)); + return; + } + + s_logger.LogWarning("MsSqlMessageProducer: no scheduler configured, message will be sent immediately"); + } + + var topic = message.Header.Topic; + + s_logger.LogDebug("MsSqlMessageProducer: send message with topic {Topic} and id {Id}", topic, message.Id); + + _sqlQ.Send(message, topic); } /// @@ -129,8 +147,30 @@ public void SendWithDelay(Message message, TimeSpan? delay = null) /// A task that represents the asynchronous send operation. public async Task SendWithDelayAsync(Message message, TimeSpan? delay, CancellationToken cancellationToken = default) { - // No delay support implemented - await SendAsync(message, cancellationToken); + delay ??= TimeSpan.Zero; + if (delay != TimeSpan.Zero) + { + if (Scheduler is IAmAMessageSchedulerAsync async) + { + await async.ScheduleAsync(message, delay.Value, cancellationToken); + return; + } + + if (Scheduler is IAmAMessageSchedulerSync sync) + { + sync.Schedule(message, delay.Value); + return; + } + + s_logger.LogWarning("MsSqlMessageProducer: no scheduler configured, message will be sent immediately"); + } + + var topic = message.Header.Topic; + + s_logger.LogDebug( + "MsSqlMessageProducer: send async message with topic {Topic} and id {Id}", topic, message.Id); + + await _sqlQ.SendAsync(message, topic, TimeSpan.Zero, cancellationToken); } /// diff --git a/src/Paramore.Brighter.MessagingGateway.RMQ/RmqMessageProducer.cs b/src/Paramore.Brighter.MessagingGateway.RMQ/RmqMessageProducer.cs index 94ad46d056..95c5328344 100644 --- a/src/Paramore.Brighter.MessagingGateway.RMQ/RmqMessageProducer.cs +++ b/src/Paramore.Brighter.MessagingGateway.RMQ/RmqMessageProducer.cs @@ -67,6 +67,11 @@ public class RmqMessageProducer : RmqMessageGateway, IAmAMessageProducerSync, IA /// The OTel Span we are writing Producer events too /// public Activity? Span { get; set; } + + /// + /// The + /// + public IAmAMessageScheduler? Scheduler { get; set; } /// /// Initializes a new instance of the class. @@ -144,13 +149,21 @@ public async Task SendWithDelayAsync(Message message, TimeSpan? delay, Cancellat _pendingConfirmations.TryAdd(await Channel.GetNextPublishSequenceNumberAsync(cancellationToken), message.Id); - if (DelaySupported) + if (delay == TimeSpan.Zero || DelaySupported) { await rmqMessagePublisher.PublishMessageAsync(message, delay.Value, cancellationToken); } + else if(Scheduler is IAmAMessageSchedulerAsync asyncScheduler) + { + await asyncScheduler.ScheduleAsync(message, delay.Value, cancellationToken); + } + else if (Scheduler is IAmAMessageSchedulerSync scheduler) + { + scheduler.Schedule(message, delay.Value); + } else { - //TODO: Replace with a Timer, don't block + s_logger.LogWarning("No scheduler configured, going to ignore delay publish"); await rmqMessagePublisher.PublishMessageAsync(message, TimeSpan.Zero, cancellationToken); } diff --git a/src/Paramore.Brighter.MessagingGateway.RMQ/RmqMessageProducerFactory.cs b/src/Paramore.Brighter.MessagingGateway.RMQ/RmqMessageProducerFactory.cs index dbbe564d05..60b21bb35a 100644 --- a/src/Paramore.Brighter.MessagingGateway.RMQ/RmqMessageProducerFactory.cs +++ b/src/Paramore.Brighter.MessagingGateway.RMQ/RmqMessageProducerFactory.cs @@ -1,4 +1,5 @@ #region Licence + /* The MIT License (MIT) Copyright © 2024 Dominic Hickie @@ -19,6 +20,7 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ + #endregion using System.Collections.Generic; @@ -37,14 +39,16 @@ public class RmqMessageProducerFactory( : IAmAMessageProducerFactory { /// - public Dictionary Create() + public Dictionary Create() { var producers = new Dictionary(); foreach (var publication in publications) { if (publication.Topic is null || RoutingKey.IsNullOrEmpty(publication.Topic)) + { throw new ConfigurationException($"A RabbitMQ publication must have a topic"); - + } + producers[publication.Topic] = new RmqMessageProducer(connection, publication); } diff --git a/src/Paramore.Brighter.MessagingGateway.Redis/RedisMessageProducer.cs b/src/Paramore.Brighter.MessagingGateway.Redis/RedisMessageProducer.cs index 55510f50b1..ee285d0d44 100644 --- a/src/Paramore.Brighter.MessagingGateway.Redis/RedisMessageProducer.cs +++ b/src/Paramore.Brighter.MessagingGateway.Redis/RedisMessageProducer.cs @@ -31,6 +31,7 @@ THE SOFTWARE. */ using Microsoft.Extensions.Logging; using Paramore.Brighter.Logging; using Paramore.Brighter.Observability; +using Paramore.Brighter.Tasks; using ServiceStack.Redis; namespace Paramore.Brighter.MessagingGateway.Redis @@ -69,7 +70,11 @@ public class RedisMessageProducer( /// public Publication Publication { get { return _publication; } } + /// public Activity? Span { get; set; } + + /// + public IAmAMessageScheduler? Scheduler { get; set; } public void Dispose() { @@ -88,33 +93,7 @@ public async ValueTask DisposeAsync() /// /// The message. /// Task. - public void Send(Message message) - { - if (s_pool is null) - throw new ChannelFailureException("RedisMessageProducer: Connection pool has not been initialized"); - - using var client = s_pool.Value.GetClient(); - Topic = message.Header.Topic; - - s_logger.LogDebug("RedisMessageProducer: Preparing to send message"); - - var redisMessage = CreateRedisMessage(message); - - s_logger.LogDebug( - "RedisMessageProducer: Publishing message with topic {Topic} and id {Id} and body: {Request}", - message.Header.Topic, message.Id.ToString(), message.Body.Value - ); - //increment a counter to get the next message id - var nextMsgId = IncrementMessageCounter(client); - //store the message, against that id - StoreMessage(client, redisMessage, nextMsgId); - //If there are subscriber queues, push the message to the subscriber queues - var pushedTo = PushToQueues(client, nextMsgId); - s_logger.LogDebug( - "RedisMessageProducer: Published message with topic {Topic} and id {Id} and body: {Request} to queues: {3}", - message.Header.Topic, message.Id.ToString(), message.Body.Value, string.Join(", ", pushedTo) - ); - } + public void Send(Message message) => SendWithDelay(message, TimeSpan.Zero); /// /// Sends the specified message. @@ -123,11 +102,45 @@ public void Send(Message message) /// A token to cancel the send operation /// Task. public async Task SendAsync(Message message, CancellationToken cancellationToken = default) + { + await SendWithDelayAsync(message, TimeSpan.Zero, cancellationToken); + } + + /// + /// Sends the specified message. + /// + /// + /// No delay support on Redis + /// + /// The message. + /// The sending delay + /// Task. + public void SendWithDelay(Message message, TimeSpan? delay = null) { if (s_pool is null) + { throw new ChannelFailureException("RedisMessageProducer: Connection pool has not been initialized"); + } + + delay ??= TimeSpan.Zero; + if (delay != TimeSpan.Zero) + { + if (Scheduler is IAmAMessageSchedulerSync sync) + { + sync.Schedule(message, delay.Value); + return; + } - await using var client = await s_pool.Value.GetClientAsync(token: cancellationToken); + if (Scheduler is IAmAMessageSchedulerAsync async) + { + BrighterAsyncContext.Run(async () => await async.ScheduleAsync(message, delay.Value)); + return; + } + + s_logger.LogInformation("RedisMessageProducer: No scheduler configured, message will be sent immediately"); + } + + using var client = s_pool.Value.GetClient(); Topic = message.Header.Topic; s_logger.LogDebug("RedisMessageProducer: Preparing to send message"); @@ -139,31 +152,17 @@ public async Task SendAsync(Message message, CancellationToken cancellationToken message.Header.Topic, message.Id.ToString(), message.Body.Value ); //increment a counter to get the next message id - var nextMsgId = await IncrementMessageCounterAsync(client, cancellationToken); + var nextMsgId = IncrementMessageCounter(client); //store the message, against that id - await StoreMessageAsync(client, redisMessage, nextMsgId); + StoreMessage(client, redisMessage, nextMsgId); //If there are subscriber queues, push the message to the subscriber queues - var pushedTo = await PushToQueuesAsync(client, nextMsgId, cancellationToken); + var pushedTo = PushToQueues(client, nextMsgId); s_logger.LogDebug( "RedisMessageProducer: Published message with topic {Topic} and id {Id} and body: {Request} to queues: {3}", message.Header.Topic, message.Id.ToString(), message.Body.Value, string.Join(", ", pushedTo) ); } - - /// - /// Sends the specified message. - /// - /// - /// No delay support on Redis - /// - /// The message. - /// The sending delay - /// Task. - public void SendWithDelay(Message message, TimeSpan? delay = null) - { - Send(message); - } - + /// /// Sends the specified message. /// @@ -172,10 +171,54 @@ public void SendWithDelay(Message message, TimeSpan? delay = null) /// /// The message. /// The sending delay + /// /// Task. public async Task SendWithDelayAsync(Message message, TimeSpan? delay, CancellationToken cancellationToken = default) { - await SendAsync(message, cancellationToken); + if (s_pool is null) + { + throw new ChannelFailureException("RedisMessageProducer: Connection pool has not been initialized"); + } + + delay ??= TimeSpan.Zero; + if (delay != TimeSpan.Zero) + { + if (Scheduler is IAmAMessageSchedulerAsync async) + { + await async.ScheduleAsync(message, delay.Value, cancellationToken); + return; + } + + if (Scheduler is IAmAMessageSchedulerSync sync) + { + sync.Schedule(message, delay.Value); + return; + } + + s_logger.LogInformation("RedisMessageProducer: No scheduler configured, message will be sent immediately"); + } + + await using var client = await s_pool.Value.GetClientAsync(token: cancellationToken); + Topic = message.Header.Topic; + + s_logger.LogDebug("RedisMessageProducer: Preparing to send message"); + + var redisMessage = CreateRedisMessage(message); + + s_logger.LogDebug( + "RedisMessageProducer: Publishing message with topic {Topic} and id {Id} and body: {Request}", + message.Header.Topic, message.Id.ToString(), message.Body.Value + ); + //increment a counter to get the next message id + var nextMsgId = await IncrementMessageCounterAsync(client, cancellationToken); + //store the message, against that id + await StoreMessageAsync(client, redisMessage, nextMsgId); + //If there are subscriber queues, push the message to the subscriber queues + var pushedTo = await PushToQueuesAsync(client, nextMsgId, cancellationToken); + s_logger.LogDebug( + "RedisMessageProducer: Published message with topic {Topic} and id {Id} and body: {Request} to queues: {3}", + message.Header.Topic, message.Id.ToString(), message.Body.Value, string.Join(", ", pushedTo) + ); } private IEnumerable PushToQueues(IRedisClient client, long nextMsgId) diff --git a/src/Paramore.Brighter.ServiceActivator/ControlBus/ControlBusReceiverBuilder.cs b/src/Paramore.Brighter.ServiceActivator/ControlBus/ControlBusReceiverBuilder.cs index 55f92b51d4..42f772379d 100644 --- a/src/Paramore.Brighter.ServiceActivator/ControlBus/ControlBusReceiverBuilder.cs +++ b/src/Paramore.Brighter.ServiceActivator/ControlBus/ControlBusReceiverBuilder.cs @@ -178,6 +178,7 @@ public Dispatcher Build(string hostName) .ExternalBus(ExternalBusType.FireAndForget, mediator) .ConfigureInstrumentation(null, InstrumentationOptions.None) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); // These are the control bus channels, we hardcode them because we want to know they exist, but we use diff --git a/src/Paramore.Brighter/CommandProcessor.cs b/src/Paramore.Brighter/CommandProcessor.cs index f044cd106c..fff2be65c3 100644 --- a/src/Paramore.Brighter/CommandProcessor.cs +++ b/src/Paramore.Brighter/CommandProcessor.cs @@ -37,6 +37,7 @@ THE SOFTWARE. */ using Paramore.Brighter.FeatureSwitch; using Paramore.Brighter.Logging; using Paramore.Brighter.Observability; +using Paramore.Brighter.Tasks; using Polly; using Polly.Registry; using Exception = System.Exception; @@ -61,6 +62,7 @@ public class CommandProcessor : IAmACommandProcessor private readonly IAmAFeatureSwitchRegistry? _featureSwitchRegistry; private readonly IEnumerable? _replySubscriptions; private readonly IAmABrighterTracer? _tracer; + private readonly IAmAMessageSchedulerFactory? _messageSchedulerFactory; //Uses -1 to indicate no outbox and will thus force a throw on a failed publish @@ -117,6 +119,7 @@ public class CommandProcessor : IAmACommandProcessor /// Do we want to insert an inbox handler into pipelines without the attribute. Null (default = no), yes = how to configure /// What is the tracer we will use for telemetry /// When creating a span for operations how noisy should the attributes be + /// The . public CommandProcessor( IAmASubscriberRegistry subscriberRegistry, IAmAHandlerFactory handlerFactory, @@ -125,7 +128,8 @@ public CommandProcessor( IAmAFeatureSwitchRegistry? featureSwitchRegistry = null, InboxConfiguration? inboxConfiguration = null, IAmABrighterTracer? tracer = null, - InstrumentationOptions instrumentationOptions = InstrumentationOptions.All) + InstrumentationOptions instrumentationOptions = InstrumentationOptions.All, + IAmAMessageSchedulerFactory? messageSchedulerFactory = null) { _subscriberRegistry = subscriberRegistry; @@ -144,6 +148,7 @@ public CommandProcessor( _inboxConfiguration = inboxConfiguration; _tracer = tracer; _instrumentationOptions = instrumentationOptions; + _messageSchedulerFactory = messageSchedulerFactory; } /// @@ -162,6 +167,7 @@ public CommandProcessor( /// If we are expecting a response, then we need a channel to listen on /// What is the tracer we will use for telemetry /// When creating a span for operations how noisy should the attributes be + /// The . public CommandProcessor( IAmASubscriberRegistry subscriberRegistry, IAmAHandlerFactory handlerFactory, @@ -173,14 +179,16 @@ public CommandProcessor( IEnumerable? replySubscriptions = null, IAmAChannelFactory? responseChannelFactory = null, IAmABrighterTracer? tracer = null, - InstrumentationOptions instrumentationOptions = InstrumentationOptions.All) - : this(subscriberRegistry, handlerFactory, requestContextFactory, policyRegistry, featureSwitchRegistry, inboxConfiguration) + InstrumentationOptions instrumentationOptions = InstrumentationOptions.All, + IAmAMessageSchedulerFactory? messageSchedulerFactory = null) + : this(subscriberRegistry, handlerFactory, requestContextFactory, policyRegistry, featureSwitchRegistry, inboxConfiguration, messageSchedulerFactory: messageSchedulerFactory) { _responseChannelFactory = responseChannelFactory; _tracer = tracer; _instrumentationOptions = instrumentationOptions; _replySubscriptions = replySubscriptions; - + _messageSchedulerFactory = messageSchedulerFactory; + InitExtServiceBus(bus); } @@ -196,6 +204,7 @@ public CommandProcessor( /// The Subscriptions for creating the reply queues /// What is the tracer we will use for telemetry /// When creating a span for operations how noisy should the attributes be + /// The . public CommandProcessor( IAmARequestContextFactory requestContextFactory, IPolicyRegistry policyRegistry, @@ -204,7 +213,8 @@ public CommandProcessor( InboxConfiguration? inboxConfiguration = null, IEnumerable? replySubscriptions = null, IAmABrighterTracer? tracer = null, - InstrumentationOptions instrumentationOptions = InstrumentationOptions.All) + InstrumentationOptions instrumentationOptions = InstrumentationOptions.All, + IAmAMessageSchedulerFactory? messageSchedulerFactory = null) { _requestContextFactory = requestContextFactory; _policyRegistry = policyRegistry; @@ -213,6 +223,7 @@ public CommandProcessor( _replySubscriptions = replySubscriptions; _tracer = tracer; _instrumentationOptions = instrumentationOptions; + _messageSchedulerFactory = messageSchedulerFactory; InitExtServiceBus(mediator); } @@ -461,6 +472,166 @@ public async Task PublishAsync( } } + /// + public string SchedulerPost(TRequest request, + TimeSpan delay, + RequestContext? requestContext = null, + Dictionary? args = null) where TRequest : class, IRequest + { + if (_messageSchedulerFactory == null) + { + throw new InvalidOperationException("No message scheduler factory defined."); + } + + s_logger.LogInformation("Scheduling a request: {RequestType} {Id}", request.GetType(), request.Id); + + var span = _tracer?.CreateSpan(CommandProcessorSpanOperation.Scheduler, request, requestContext?.Span, options: _instrumentationOptions); + var context = InitRequestContext(span, requestContext); + + try + { + Message message = s_mediator!.CreateMessageFromRequest(request, context); + + var scheduler = _messageSchedulerFactory.Create(this); + return scheduler switch + { + IAmAMessageSchedulerSync sync => sync.Schedule(message, delay), + IAmAMessageSchedulerAsync async => BrighterAsyncContext.Run(async () => await async.ScheduleAsync(message, delay)), + _ => throw new InvalidOperationException("Message scheduler must be sync or async") + }; + } + catch (Exception e) + { + _tracer?.AddExceptionToSpan(span, [e]); + throw; + } + finally + { + _tracer?.EndSpan(span); + } + } + + /// + public string SchedulerPost(TRequest request, + DateTimeOffset at, + RequestContext? requestContext = null, + Dictionary? args = null) where TRequest : class, IRequest + { + if (_messageSchedulerFactory == null) + { + throw new InvalidOperationException("No message scheduler factory defined."); + } + + s_logger.LogInformation("Scheduling a request: {RequestType} {Id}", request.GetType(), request.Id); + + var span = _tracer?.CreateSpan(CommandProcessorSpanOperation.Scheduler, request, requestContext?.Span, options: _instrumentationOptions); + var context = InitRequestContext(span, requestContext); + + try + { + Message message = s_mediator!.CreateMessageFromRequest(request, context); + + var scheduler = _messageSchedulerFactory.Create(this); + return scheduler switch + { + IAmAMessageSchedulerSync sync => sync.Schedule(message, at), + IAmAMessageSchedulerAsync async => BrighterAsyncContext.Run(async () => await async.ScheduleAsync(message, at)), + _ => throw new InvalidOperationException("Message scheduler must be sync or async") + }; + } + catch (Exception e) + { + _tracer?.AddExceptionToSpan(span, [e]); + throw; + } + finally + { + _tracer?.EndSpan(span); + } + } + + /// + public async Task SchedulerPostAsync(TRequest request, + TimeSpan delay, + RequestContext? requestContext = null, + Dictionary? args = null, + bool continueOnCapturedContext = true, + CancellationToken cancellationToken = default) where TRequest : class, IRequest + { + if (_messageSchedulerFactory == null) + { + throw new InvalidOperationException("No message scheduler factory defined."); + } + + s_logger.LogInformation("Scheduling a request: {RequestType} {Id}", request.GetType(), request.Id); + + var span = _tracer?.CreateSpan(CommandProcessorSpanOperation.Scheduler, request, requestContext?.Span, options: _instrumentationOptions); + var context = InitRequestContext(span, requestContext); + + try + { + Message message = await s_mediator!.CreateMessageFromRequestAsync(request, context, cancellationToken); + + var scheduler = _messageSchedulerFactory.Create(this); + return scheduler switch + { + IAmAMessageSchedulerAsync async => await async.ScheduleAsync(message, delay, cancellationToken).ConfigureAwait(continueOnCapturedContext), + IAmAMessageSchedulerSync sync => sync.Schedule(message, delay), + _ => throw new InvalidOperationException("Message scheduler must be sync or async") + }; + } + catch (Exception e) + { + _tracer?.AddExceptionToSpan(span, [e]); + throw; + } + finally + { + _tracer?.EndSpan(span); + } + } + + public async Task SchedulerPostAsync(TRequest request, + DateTimeOffset at, + RequestContext? requestContext = null, + Dictionary? args = null, + bool continueOnCapturedContext = true, + CancellationToken cancellationToken = default) + where TRequest : class, IRequest + { + if (_messageSchedulerFactory == null) + { + throw new InvalidOperationException("No message scheduler factory defined."); + } + + s_logger.LogInformation("Scheduling a request: {RequestType} {Id}", request.GetType(), request.Id); + + var span = _tracer?.CreateSpan(CommandProcessorSpanOperation.Scheduler, request, requestContext?.Span, options: _instrumentationOptions); + var context = InitRequestContext(span, requestContext); + + try + { + Message message = await s_mediator!.CreateMessageFromRequestAsync(request, context, cancellationToken); + + var scheduler = _messageSchedulerFactory.Create(this); + return scheduler switch + { + IAmAMessageSchedulerAsync async => await async.ScheduleAsync(message, at, cancellationToken).ConfigureAwait(continueOnCapturedContext), + IAmAMessageSchedulerSync sync => sync.Schedule(message, at), + _ => throw new InvalidOperationException("Message scheduler must be sync or async") + }; + } + catch (Exception e) + { + _tracer?.AddExceptionToSpan(span, [e]); + throw; + } + finally + { + _tracer?.EndSpan(span); + } + } + /// /// Posts the specified request. The message is placed on a task queue and into a outbox for reposting in the event of failure. /// You will need to configure a service that reads from the task queue to process the message diff --git a/src/Paramore.Brighter/CommandProcessorBuilder.cs b/src/Paramore.Brighter/CommandProcessorBuilder.cs index 10217804e3..5f3f3e08e7 100644 --- a/src/Paramore.Brighter/CommandProcessorBuilder.cs +++ b/src/Paramore.Brighter/CommandProcessorBuilder.cs @@ -1,4 +1,5 @@ #region Licence + /* The MIT License (MIT) Copyright © 2014 Ian Cooper @@ -78,7 +79,13 @@ namespace Paramore.Brighter /// /// /// - public class CommandProcessorBuilder : INeedAHandlers, INeedPolicy, INeedMessaging, INeedInstrumentation, INeedARequestContext, IAmACommandProcessorBuilder + public class CommandProcessorBuilder : INeedAHandlers, + INeedPolicy, + INeedMessaging, + INeedInstrumentation, + INeedARequestContext, + INeedAMessageSchedulerFactory, + IAmACommandProcessorBuilder { private IAmARequestContextFactory? _requestContextFactory; private IAmASubscriberRegistry? _registry; @@ -93,6 +100,7 @@ public class CommandProcessorBuilder : INeedAHandlers, INeedPolicy, INeedMessagi private InboxConfiguration? _inboxConfiguration; private InstrumentationOptions? _instrumetationOptions; private IAmABrighterTracer? _tracer; + private IAmAMessageSchedulerFactory? _messageSchedulerFactory; private CommandProcessorBuilder() { @@ -142,11 +150,13 @@ public INeedAHandlers ConfigureFeatureSwitches(IAmAFeatureSwitchRegistry feature public INeedMessaging Policies(IPolicyRegistry policyRegistry) { if (!policyRegistry.ContainsKey(CommandProcessor.RETRYPOLICY)) - throw new ConfigurationException("The policy registry is missing the CommandProcessor.RETRYPOLICY policy which is required"); - + throw new ConfigurationException( + "The policy registry is missing the CommandProcessor.RETRYPOLICY policy which is required"); + if (!policyRegistry.ContainsKey(CommandProcessor.CIRCUITBREAKER)) - throw new ConfigurationException("The policy registry is missing the CommandProcessor.CIRCUITBREAKER policy which is required"); - + throw new ConfigurationException( + "The policy registry is missing the CommandProcessor.CIRCUITBREAKER policy which is required"); + _policyRegistry = policyRegistry; return this; } @@ -171,17 +181,19 @@ public INeedMessaging DefaultPolicy() /// A factory for channels used to handle RPC responses /// If we use a request reply queue how do we subscribe to replies /// What inbox do we use for request-reply + /// The message scheduler factory /// public INeedInstrumentation ExternalBus( - ExternalBusType busType, - IAmAnOutboxProducerMediator bus, - IAmAChannelFactory? responseChannelFactory = null, + ExternalBusType busType, + IAmAnOutboxProducerMediator bus, + IAmAChannelFactory? responseChannelFactory = null, IEnumerable? subscriptions = null, - InboxConfiguration? inboxConfiguration = null + InboxConfiguration? inboxConfiguration = null, + IAmAMessageSchedulerFactory? messageSchedulerFactory = null ) { _inboxConfiguration = inboxConfiguration; - + switch (busType) { case ExternalBusType.None: @@ -194,6 +206,7 @@ public INeedInstrumentation ExternalBus( _useRequestReplyQueues = true; _replySubscriptions = subscriptions; _responseChannelFactory = responseChannelFactory; + _messageSchedulerFactory = messageSchedulerFactory; break; default: throw new ConfigurationException("Bus type not supported"); @@ -201,7 +214,7 @@ public INeedInstrumentation ExternalBus( return this; } - + /// /// Use to indicate that you are not using Task Queues. /// @@ -222,13 +235,14 @@ public INeedInstrumentation NoExternalBus() /// What is the that we will use to instrument the Command Processor /// A that tells us how detailed the instrumentation should be /// - public INeedARequestContext ConfigureInstrumentation(IAmABrighterTracer? tracer, InstrumentationOptions instrumentationOptions) + public INeedARequestContext ConfigureInstrumentation(IAmABrighterTracer? tracer, + InstrumentationOptions instrumentationOptions) { - _tracer = tracer; - _instrumetationOptions = instrumentationOptions; - return this; + _tracer = tracer; + _instrumetationOptions = instrumentationOptions; + return this; } - + /// /// We do not intend to instrument the CommandProcessor /// @@ -245,75 +259,86 @@ public INeedARequestContext NoInstrumentation() /// /// The request context factory. /// IAmACommandProcessorBuilder. - public IAmACommandProcessorBuilder RequestContextFactory(IAmARequestContextFactory requestContextFactory) + public INeedAMessageSchedulerFactory RequestContextFactory(IAmARequestContextFactory requestContextFactory) { _requestContextFactory = requestContextFactory; return this; } + /// + public IAmACommandProcessorBuilder MessageSchedulerFactory(IAmAMessageSchedulerFactory? messageSchedulerFactory) + { + _messageSchedulerFactory = messageSchedulerFactory; + return this; + } + /// /// Builds the from the configuration. /// /// CommandProcessor. public CommandProcessor Build() { - if(_registry == null) + if (_registry == null) throw new ConfigurationException( "A SubscriberRegistry must be provided to construct a command processor"); - if(_handlerFactory == null) + if (_handlerFactory == null) throw new ConfigurationException( "A HandlerFactory must be provided to construct a command processor"); - if(_requestContextFactory == null) + if (_requestContextFactory == null) throw new ConfigurationException( "A RequestContextFactory must be provided to construct a command processor"); - if(_policyRegistry == null) + if (_policyRegistry == null) throw new ConfigurationException( "A PolicyRegistry must be provided to construct a command processor"); - if(_instrumetationOptions == null) + if (_instrumetationOptions == null) throw new ConfigurationException( "InstrumentationOptions must be provided to construct a command processor"); - - if (_bus == null) + + if (_bus == null) { - return new CommandProcessor(subscriberRegistry: _registry, handlerFactory: _handlerFactory, + return new CommandProcessor(subscriberRegistry: _registry, handlerFactory: _handlerFactory, requestContextFactory: _requestContextFactory, policyRegistry: _policyRegistry, - featureSwitchRegistry: _featureSwitchRegistry, instrumentationOptions: _instrumetationOptions.Value); + featureSwitchRegistry: _featureSwitchRegistry, + instrumentationOptions: _instrumetationOptions.Value); } - + if (!_useRequestReplyQueues) return new CommandProcessor( - subscriberRegistry: _registry, + subscriberRegistry: _registry, handlerFactory: _handlerFactory, - requestContextFactory: _requestContextFactory, + requestContextFactory: _requestContextFactory, policyRegistry: _policyRegistry, bus: _bus, - featureSwitchRegistry: _featureSwitchRegistry, + featureSwitchRegistry: _featureSwitchRegistry, inboxConfiguration: _inboxConfiguration, tracer: _tracer, - instrumentationOptions: _instrumetationOptions.Value + instrumentationOptions: _instrumetationOptions.Value, + messageSchedulerFactory: _messageSchedulerFactory ); - + if (_useRequestReplyQueues) return new CommandProcessor( - subscriberRegistry: _registry, + subscriberRegistry: _registry, handlerFactory: _handlerFactory, - requestContextFactory: _requestContextFactory, + requestContextFactory: _requestContextFactory, policyRegistry: _policyRegistry, bus: _bus, - featureSwitchRegistry: _featureSwitchRegistry, + featureSwitchRegistry: _featureSwitchRegistry, inboxConfiguration: _inboxConfiguration, replySubscriptions: _replySubscriptions, responseChannelFactory: _responseChannelFactory, tracer: _tracer, - instrumentationOptions: _instrumetationOptions.Value + instrumentationOptions: _instrumetationOptions.Value, + messageSchedulerFactory: _messageSchedulerFactory ); throw new ConfigurationException( "The configuration options chosen cannot be used to construct a command processor"); - } + } } #region Progressive interfaces + /// /// Interface INeedAHandlers /// @@ -325,7 +350,7 @@ public interface INeedAHandlers /// The registry. /// INeedPolicy. INeedPolicy Handlers(HandlerConfiguration theRegistry); - + /// /// Configure Feature Switches for the Handlers /// @@ -345,6 +370,7 @@ public interface INeedPolicy /// The policy registry. /// INeedLogging. INeedMessaging Policies(IPolicyRegistry policyRegistry); + /// /// Knows the policy. /// @@ -352,7 +378,7 @@ public interface INeedPolicy INeedMessaging DefaultPolicy(); } - + /// /// Interface INeedMessaging /// Note that a single command builder does not support both task queues and rpc, using the builder @@ -369,14 +395,16 @@ public interface INeedMessaging /// If using RPC the factory for reply channels /// If using RPC, any reply subscriptions /// What is the inbox configuration + /// The message scheduler factory. /// INeedInstrumentation ExternalBus( - ExternalBusType busType, - IAmAnOutboxProducerMediator bus, - IAmAChannelFactory? responseChannelFactory = null, + ExternalBusType busType, + IAmAnOutboxProducerMediator bus, + IAmAChannelFactory? responseChannelFactory = null, IEnumerable? subscriptions = null, - InboxConfiguration? inboxConfiguration = null - ); + InboxConfiguration? inboxConfiguration = null, + IAmAMessageSchedulerFactory? messageSchedulerFactory = null + ); /// /// We don't send messages out of process @@ -399,8 +427,9 @@ public interface INeedInstrumentation /// InstrumentationOptions.All - all of the above /// /// INeedARequestContext - INeedARequestContext ConfigureInstrumentation(IAmABrighterTracer? tracer, InstrumentationOptions instrumentationOptions); - + INeedARequestContext ConfigureInstrumentation(IAmABrighterTracer? tracer, + InstrumentationOptions instrumentationOptions); + /// /// We don't need instrumentation of the CommandProcessor /// @@ -408,6 +437,7 @@ public interface INeedInstrumentation INeedARequestContext NoInstrumentation(); } + /// /// Interface INeedARequestContext /// @@ -418,9 +448,20 @@ public interface INeedARequestContext /// /// The request context factory. /// IAmACommandProcessorBuilder. - IAmACommandProcessorBuilder RequestContextFactory(IAmARequestContextFactory requestContextFactory); + INeedAMessageSchedulerFactory RequestContextFactory(IAmARequestContextFactory requestContextFactory); + } + + public interface INeedAMessageSchedulerFactory + { + /// + /// The . + /// + /// + /// + IAmACommandProcessorBuilder MessageSchedulerFactory(IAmAMessageSchedulerFactory? messageSchedulerFactory); } - + + /// /// Interface IAmACommandProcessorBuilder /// @@ -432,5 +473,6 @@ public interface IAmACommandProcessorBuilder /// CommandProcessor. CommandProcessor Build(); } + #endregion } diff --git a/src/Paramore.Brighter/ControlBusSenderFactory.cs b/src/Paramore.Brighter/ControlBusSenderFactory.cs index 20d9317f29..d930e0fad4 100644 --- a/src/Paramore.Brighter/ControlBusSenderFactory.cs +++ b/src/Paramore.Brighter/ControlBusSenderFactory.cs @@ -40,8 +40,14 @@ public class ControlBusSenderFactory : IAmAControlBusSenderFactory /// Creates the specified configuration. /// /// The outbox for outgoing messages to the control bus + /// + /// + /// /// IAmAControlBusSender. - public IAmAControlBusSender Create(IAmAnOutbox outbox, IAmAProducerRegistry producerRegistry, BrighterTracer tracer) + public IAmAControlBusSender Create(IAmAnOutbox outbox, + IAmAProducerRegistry producerRegistry, + BrighterTracer tracer, + IAmAMessageSchedulerFactory? messageSchedulerFactory = null) where T : Message { var mapper = new MessageMapperRegistry( @@ -65,6 +71,7 @@ public IAmAControlBusSender Create(IAmAnOutbox outbox, IAmAProd .ExternalBus(ExternalBusType.FireAndForget, mediator) .ConfigureInstrumentation(null, InstrumentationOptions.None) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(messageSchedulerFactory) .Build() ); } diff --git a/src/Paramore.Brighter/ExternalBusConfiguration.cs b/src/Paramore.Brighter/ExternalBusConfiguration.cs index d46741b972..69a04afae9 100644 --- a/src/Paramore.Brighter/ExternalBusConfiguration.cs +++ b/src/Paramore.Brighter/ExternalBusConfiguration.cs @@ -118,6 +118,11 @@ public interface IAmExternalBusConfiguration /// IAmARequestContextFactory? RequestContextFactory { get; set; } + /// + /// The Message Scheduler Factory. + /// + IAmAMessageSchedulerFactory? MessageSchedulerFactory { get; set; } + /// /// The transaction provider for the outbox /// @@ -239,6 +244,11 @@ public class ExternalBusConfiguration : IAmExternalBusConfiguration /// public IAmAMessageTransformerFactory? TransformerFactory { get; set; } + /// + /// The Message Scheduler Factory. + /// + public IAmAMessageSchedulerFactory? MessageSchedulerFactory { get; set; } + /// /// The transaction provider for the outbox /// NOTE: Must implement IAmABoxTransactionProvider< > diff --git a/src/Paramore.Brighter/IAmACommandProcessor.cs b/src/Paramore.Brighter/IAmACommandProcessor.cs index e59337f811..56fd31ea3b 100644 --- a/src/Paramore.Brighter/IAmACommandProcessor.cs +++ b/src/Paramore.Brighter/IAmACommandProcessor.cs @@ -44,7 +44,7 @@ public interface IAmACommandProcessor /// /// /// The command. - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a void Send(TRequest command, RequestContext? requestContext = null) where TRequest : class, IRequest; /// @@ -52,7 +52,8 @@ public interface IAmACommandProcessor /// /// /// The command. - /// The context of the request; if null we will start one via a /// Should we use the calling thread's synchronization context when continuing or a default thread synchronization context. Defaults to false + /// The context of the request; if null we will start one via a + /// Should we use the calling thread's synchronization context when continuing or a default thread synchronization context. Defaults to false /// Allows the sender to cancel the request pipeline. Optional /// awaitable . Task SendAsync(TRequest command, RequestContext? requestContext = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest; @@ -61,7 +62,8 @@ public interface IAmACommandProcessor /// Publishes the specified event. Throws an aggregate exception on failure of a pipeline but executes remaining /// /// - /// The context of the request; if null we will start one via a /// The event. + /// The context of the request; if null we will start one via a + /// The event. void Publish(TRequest @event, RequestContext? requestContext = null) where TRequest : class, IRequest; /// @@ -69,7 +71,7 @@ public interface IAmACommandProcessor /// /// /// The event. - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// Should we use the calling thread's synchronization context when continuing or a default thread synchronization context. Defaults to false /// Allows the sender to cancel the request pipeline. Optional /// awaitable . @@ -85,7 +87,67 @@ Task PublishAsync( /// /// The type of the request /// The request. - /// The context of the request; if null we will start one via a + /// The amount of delay before post the message. + /// The context of the request; if null we will start one via a + /// For transports or outboxes that require additional parameters such as topic, provide an optional arg + string SchedulerPost(TRequest request, TimeSpan delay, RequestContext? requestContext= null, Dictionary? args = null) where TRequest : class, IRequest; + + /// + /// Posts the specified request. + /// + /// The type of the request + /// The request. + /// The date-time the message should be post. + /// The context of the request; if null we will start one via a + /// For transports or outboxes that require additional parameters such as topic, provide an optional arg + string SchedulerPost(TRequest request, DateTimeOffset at, RequestContext? requestContext= null, Dictionary? args = null) where TRequest : class, IRequest; + + /// + /// Posts the specified request with async/await support. + /// + /// The type of the request + /// The request. + /// The amount of delay before post the message. + /// The context of the request; if null we will start one via a + /// Should we use the calling thread's synchronization context when continuing or a default thread synchronization context. Defaults to false + /// For transports or outboxes that require additional parameters such as topic, provide an optional arg + /// Allows the sender to cancel the request pipeline. Optional + /// awaitable . + Task SchedulerPostAsync( + TRequest request, + TimeSpan delay, + RequestContext? requestContext = null, + Dictionary? args = null, + bool continueOnCapturedContext = true, + CancellationToken cancellationToken = default + ) where TRequest : class, IRequest; + + /// + /// Posts the specified request with async/await support. + /// + /// The type of the request + /// The request. + /// The date-time the message should be post. + /// The context of the request; if null we will start one via a + /// Should we use the calling thread's synchronization context when continuing or a default thread synchronization context. Defaults to false + /// For transports or outboxes that require additional parameters such as topic, provide an optional arg + /// Allows the sender to cancel the request pipeline. Optional + /// awaitable . + Task SchedulerPostAsync( + TRequest request, + DateTimeOffset at, + RequestContext? requestContext = null, + Dictionary? args = null, + bool continueOnCapturedContext = true, + CancellationToken cancellationToken = default + ) where TRequest : class, IRequest; + + /// + /// Posts the specified request. + /// + /// The type of the request + /// The request. + /// The context of the request; if null we will start one via a /// For transports or outboxes that require additional parameters such as topic, provide an optional arg void Post(TRequest request, RequestContext? requestContext= null, Dictionary? args = null) where TRequest : class, IRequest; @@ -94,7 +156,7 @@ Task PublishAsync( /// /// The type of the request /// The request. - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// Should we use the calling thread's synchronization context when continuing or a default thread synchronization context. Defaults to false /// For transports or outboxes that require additional parameters such as topic, provide an optional arg /// Allows the sender to cancel the request pipeline. Optional @@ -115,7 +177,7 @@ Task PostAsync( /// Pass deposited message to /// /// The request to save to the outbox - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// For transports or outboxes that require additional parameters such as topic, provide an optional arg /// The type of the request /// @@ -130,7 +192,7 @@ Task PostAsync( /// /// The request to save to the outbox /// If using an Outbox, the transaction provider for the Outbox - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// For transports or outboxes that require additional parameters such as topic, provide an optional arg /// The id of the deposit batch, if this isn't set items will be added to the outbox as they come in and not as a batch /// The type of the request @@ -152,7 +214,7 @@ string DepositPost( /// Pass deposited message to /// /// The requests to save to the outbox - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// For transports or outboxes that require additional parameters such as topic, provide an optional arg /// The type of the request /// The Id of the Message that has been deposited. @@ -163,11 +225,11 @@ string DepositPost( /// Intended for use with the Outbox pattern: http://gistlabs.com/2014/05/the-outbox/ normally you include the /// call to DepositPostBox within the scope of the transaction to write corresponding entity state to your /// database, that you want to signal via the request to downstream consumers - /// Pass deposited message to + /// Pass deposited message to /// /// The requests to save to the outbox /// If using an Outbox, the transaction provider for the Outbox - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// For transports or outboxes that require additional parameters such as topic, provide an optional arg /// The type of the request /// The type of transaction used by the outbox @@ -187,7 +249,7 @@ string[] DepositPost( /// Pass deposited message to /// /// The request to save to the outbox - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// For outboxes that require additional parameters such as topic, provide an optional arg /// Should we use the calling thread's synchronization context when continuing or a default thread synchronization context. Defaults to false /// The Cancellation Token. @@ -211,7 +273,7 @@ Task DepositPostAsync( /// /// The request to save to the outbox /// If using an Outbox, the transaction provider for the Outbox - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// For transports or outboxes that require additional parameters such as topic, provide an optional arg /// Should we use the calling thread's synchronization context when continuing or a default thread synchronization context. Defaults to false /// The Cancellation Token. @@ -237,12 +299,11 @@ Task DepositPostAsync( /// Pass deposited message to /// /// The requests to save to the outbox - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// For transports or outboxes that require additional parameters such as topic, provide an optional arg /// Should we use the calling thread's synchronization context when continuing or a default thread synchronization context. Defaults to false /// The Cancellation Token. /// The type of the request - /// The type of transaction used by the outbox /// Task DepositPostAsync( IEnumerable requests, @@ -261,7 +322,7 @@ Task DepositPostAsync( /// /// The requests to save to the outbox /// If using an Outbox, the transaction provider for the Outbox - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// For transports or outboxes that require additional parameters such as topic, provide an optional arg /// Should we use the calling thread's synchronization context when continuing or a default thread synchronization context. Defaults to false /// The Cancellation Token. @@ -279,19 +340,19 @@ Task DepositPostAsync( /// /// Flushes the message box message given by to the broker. - /// Intended for use with the Outbox pattern: http://gistlabs.com/2014/05/the-outbox/ + /// Intended for use with the Outbox pattern: http://gistlabs.com/2014/05/the-outbox/ /// /// The ids to flush - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// For transports or outboxes that require additional parameters such as topic, provide an optional arg void ClearOutbox(string[] ids, RequestContext? requestContext = null, Dictionary? args = null); /// /// Flushes the message box message given by to the broker. - /// Intended for use with the Outbox pattern: http://gistlabs.com/2014/05/the-outbox/ + /// Intended for use with the Outbox pattern: http://gistlabs.com/2014/05/the-outbox/ /// /// The ids to flush - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// For transports or outboxes that require additional parameters such as topic, provide an optional arg /// /// @@ -311,7 +372,7 @@ Task ClearOutboxAsync( /// Because the operation blocks, there is a mandatory timeout /// /// What message do we want a reply to - /// The context of the request; if null we will start one via a + /// The context of the request; if null we will start one via a /// The call blocks, so we must time out; defaults to 500 ms if null /// TResponse? Call(T request, RequestContext? requestContext = null, TimeSpan? timeOut = null) diff --git a/src/Paramore.Brighter/IAmAControlBusSenderFactory.cs b/src/Paramore.Brighter/IAmAControlBusSenderFactory.cs index e11cadffbe..9384fc8693 100644 --- a/src/Paramore.Brighter/IAmAControlBusSenderFactory.cs +++ b/src/Paramore.Brighter/IAmAControlBusSenderFactory.cs @@ -40,11 +40,13 @@ public interface IAmAControlBusSenderFactory { /// The outbox to record outbound messages on the control bus /// The list of producers to send with /// The tracer lets us create open telemetry information + /// The message scheduler factory. /// IAmAControlBusSender. IAmAControlBusSender Create( IAmAnOutbox outbox, IAmAProducerRegistry producerRegistry, - BrighterTracer tracer + BrighterTracer tracer, + IAmAMessageSchedulerFactory? messageSchedulerFactory = null ) where T: Message; } diff --git a/src/Paramore.Brighter/IAmAMessageProducer.cs b/src/Paramore.Brighter/IAmAMessageProducer.cs index 15a3b30204..7cd50abf0e 100644 --- a/src/Paramore.Brighter/IAmAMessageProducer.cs +++ b/src/Paramore.Brighter/IAmAMessageProducer.cs @@ -39,5 +39,10 @@ public interface IAmAMessageProducer /// Allows us to set a to let a Producer participate in our telemetry /// Activity? Span { get; set; } + + /// + /// The external message scheduler + /// + IAmAMessageScheduler? Scheduler { get; set; } } } diff --git a/src/Paramore.Brighter/IAmAMessageProducerAsync.cs b/src/Paramore.Brighter/IAmAMessageProducerAsync.cs index 9b5e16125e..e4bdeb2692 100644 --- a/src/Paramore.Brighter/IAmAMessageProducerAsync.cs +++ b/src/Paramore.Brighter/IAmAMessageProducerAsync.cs @@ -45,6 +45,7 @@ public interface IAmAMessageProducerAsync : IAmAMessageProducer, IAsyncDisposabl /// Sends the specified message. /// /// The message. + /// A cancellation token to end the operation Task SendAsync(Message message, CancellationToken cancellationToken = default); /// diff --git a/src/Paramore.Brighter/IAmAMessageScheduler.cs b/src/Paramore.Brighter/IAmAMessageScheduler.cs new file mode 100644 index 0000000000..588c846d25 --- /dev/null +++ b/src/Paramore.Brighter/IAmAMessageScheduler.cs @@ -0,0 +1,5 @@ +namespace Paramore.Brighter; + +public interface IAmAMessageScheduler +{ +} diff --git a/src/Paramore.Brighter/IAmAMessageSchedulerAsync.cs b/src/Paramore.Brighter/IAmAMessageSchedulerAsync.cs new file mode 100644 index 0000000000..40cd4a06c5 --- /dev/null +++ b/src/Paramore.Brighter/IAmAMessageSchedulerAsync.cs @@ -0,0 +1,55 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Paramore.Brighter; + +/// +/// The async API for message scheduler (like in-memory, Hang fire and others) +/// +public interface IAmAMessageSchedulerAsync : IAmAMessageScheduler, IAsyncDisposable +{ + /// + /// Scheduler a message to be executed the provided . + /// + /// The . + /// The date-time of when a message should be placed. + /// A cancellation token to end the operation + /// The scheduler id. + Task ScheduleAsync(Message message, DateTimeOffset at, CancellationToken cancellationToken = default); + + /// + /// Scheduler a message to be executed the provided . + /// + /// The . + /// The of delay before place the message. + /// A cancellation token to end the operation + /// The scheduler id. + Task ScheduleAsync(Message message, TimeSpan delay, CancellationToken cancellationToken = default); + + /// + /// ReScheduler a message. + /// + /// The scheduler id. + /// The date-time of when a message should be placed. + /// A cancellation token to end the operation + /// true if it could be re-scheduler, otherwise false. + Task ReSchedulerAsync(string schedulerId, DateTimeOffset at, CancellationToken cancellationToken = default); + + /// + /// ReScheduler a message. + /// + /// The scheduler id. + /// The of delay before place the message. + /// A cancellation token to end the operation + /// true if it could be re-scheduler, otherwise false. + Task ReSchedulerAsync(string schedulerId, TimeSpan delay, CancellationToken cancellationToken = default); + + /// + /// Cancel the scheduler message + /// + /// The scheduler id. + /// A cancellation token to end the operation + /// + Task CancelAsync(string id, CancellationToken cancellationToken = default); +} diff --git a/src/Paramore.Brighter/IAmAMessageSchedulerFactory.cs b/src/Paramore.Brighter/IAmAMessageSchedulerFactory.cs new file mode 100644 index 0000000000..a30ecedf52 --- /dev/null +++ b/src/Paramore.Brighter/IAmAMessageSchedulerFactory.cs @@ -0,0 +1,14 @@ +namespace Paramore.Brighter; + +/// +/// The factory. +/// +public interface IAmAMessageSchedulerFactory +{ + /// + /// Get or create a new instance of + /// + /// + /// The . + IAmAMessageScheduler Create(IAmACommandProcessor processor); +} diff --git a/src/Paramore.Brighter/IAmAMessageSchedulerSync.cs b/src/Paramore.Brighter/IAmAMessageSchedulerSync.cs new file mode 100644 index 0000000000..44d14dc3be --- /dev/null +++ b/src/Paramore.Brighter/IAmAMessageSchedulerSync.cs @@ -0,0 +1,47 @@ +using System; + +namespace Paramore.Brighter; + +/// +/// The API for message scheduler (like in-memory, Hang fire and others) +/// +public interface IAmAMessageSchedulerSync : IAmAMessageScheduler, IDisposable +{ + /// + /// Scheduler a message to be executed the provided . + /// + /// The . + /// The date-time of when a message should be placed. + /// The scheduler id. + string Schedule(Message message, DateTimeOffset at); + + /// + /// Scheduler a message to be executed the provided . + /// + /// The . + /// The of delay before place the message. + /// The scheduler id. + string Schedule(Message message, TimeSpan delay); + + /// + /// ReScheduler a message. + /// + /// The scheduler id. + /// The date-time of when a message should be placed. + /// true if it could be re-scheduler, otherwise false. + bool ReScheduler(string schedulerId, DateTimeOffset at); + + /// + /// ReScheduler a message. + /// + /// The scheduler id. + /// The of delay before place the message. + /// true if it could be re-scheduler, otherwise false. + bool ReScheduler(string schedulerId, TimeSpan delay); + + /// + /// Cancel the scheduler message + /// + /// The scheduler id. + void Cancel(string id); +} diff --git a/src/Paramore.Brighter/IAmAnOutboxProducerMediator.cs b/src/Paramore.Brighter/IAmAnOutboxProducerMediator.cs index 9d0e34cb3b..0fe7df1d37 100644 --- a/src/Paramore.Brighter/IAmAnOutboxProducerMediator.cs +++ b/src/Paramore.Brighter/IAmAnOutboxProducerMediator.cs @@ -15,7 +15,7 @@ public interface IAmAnOutboxProducerMediator : IDisposable /// Used with RPC to call a remote service via the external bus /// /// The message to send - /// The context of the request pipeline + /// The context of the request pipeline /// The type of the call /// The type of the response void CallViaExternalBus(Message outMessage, RequestContext? requestContext) diff --git a/src/Paramore.Brighter/InMemoryMessageScheduler.cs b/src/Paramore.Brighter/InMemoryMessageScheduler.cs new file mode 100644 index 0000000000..9e460bc03b --- /dev/null +++ b/src/Paramore.Brighter/InMemoryMessageScheduler.cs @@ -0,0 +1,149 @@ +using System; +using System.Collections.Concurrent; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Paramore.Brighter.Logging; +using Paramore.Brighter.Scheduler.Events; +using Paramore.Brighter.Tasks; +using InvalidOperationException = System.InvalidOperationException; + +namespace Paramore.Brighter; + +public class InMemoryMessageScheduler( + IAmACommandProcessor processor, + TimeProvider timeProvider, + Func getOrCreateSchedulerId, + OnSchedulerConflict onConflict) + : IAmAMessageSchedulerSync, IAmAMessageSchedulerAsync +{ + private static readonly ConcurrentDictionary s_timers = new(); + private static readonly ILogger Logger = ApplicationLogging.CreateLogger(); + + /// + public string Schedule(Message message, DateTimeOffset at) + => Schedule(message, at - DateTimeOffset.UtcNow); + + /// + public string Schedule(Message message, TimeSpan delay) + { + var id = getOrCreateSchedulerId(message); + if (s_timers.TryGetValue(id, out var timer)) + { + if (onConflict == OnSchedulerConflict.Throw) + { + throw new InvalidOperationException($"scheduler with '{id}' id already exists"); + } + + timer.Dispose(); + } + + s_timers[id] = timeProvider.CreateTimer(Execute, (processor, id, message, false), delay, TimeSpan.Zero); + return id; + } + + /// + public bool ReScheduler(string schedulerId, DateTimeOffset at) + => ReScheduler(schedulerId, at - timeProvider.GetUtcNow()); + + /// + public bool ReScheduler(string schedulerId, TimeSpan delay) + { + if (s_timers.TryGetValue(schedulerId, out var timer)) + { + timer.Change(delay, TimeSpan.Zero); + return true; + } + + return false; + } + + /// + public void Cancel(string id) + { + if (s_timers.TryRemove(id, out var timer)) + { + timer.Dispose(); + } + } + + /// + public async Task ScheduleAsync(Message message, DateTimeOffset at, + CancellationToken cancellationToken = default) + => await ScheduleAsync(message, at - timeProvider.GetUtcNow(), cancellationToken); + + /// + public async Task ScheduleAsync(Message message, TimeSpan delay, + CancellationToken cancellationToken = default) + { + var id = getOrCreateSchedulerId(message); + if (s_timers.TryGetValue(id, out var timer)) + { + if (onConflict == OnSchedulerConflict.Throw) + { + throw new InvalidOperationException($"scheduler with '{id}' id already exists"); + } + + await timer.DisposeAsync(); + } + + s_timers[id] = timeProvider.CreateTimer(Execute, (processor, id, message, true), delay, TimeSpan.Zero); + return id; + } + + /// + public Task ReSchedulerAsync(string schedulerId, DateTimeOffset at, + CancellationToken cancellationToken = default) + => Task.FromResult(ReScheduler(schedulerId, at)); + + /// + public Task ReSchedulerAsync(string schedulerId, TimeSpan delay, + CancellationToken cancellationToken = default) + => Task.FromResult(ReScheduler(schedulerId, delay)); + + /// + public async Task CancelAsync(string id, CancellationToken cancellationToken = default) + { + if (s_timers.TryRemove(id, out var timer)) + { + await timer.DisposeAsync(); + } + } + + /// + public void Dispose() + { + } + + /// + public ValueTask DisposeAsync() + { + return new ValueTask(); + } + + private static void Execute(object? state) + { + var (processor, id, message, async) = ((IAmACommandProcessor, string, Message, bool))state!; + try + { + if (async) + { + BrighterAsyncContext.Run(async () => + await processor.PostAsync(new FireSchedulerMessage { Id = id, Message = message })); + } + else + { + processor.Post(new FireSchedulerMessage { Id = id, Message = message }); + } + + if (s_timers.TryRemove(id, out var timer)) + { + timer.Dispose(); + } + } + catch (Exception e) + { + Logger.LogError(e, "Error during processing scheduler {Id}", id); + } + } +} diff --git a/src/Paramore.Brighter/InMemoryMessageSchedulerFactory.cs b/src/Paramore.Brighter/InMemoryMessageSchedulerFactory.cs new file mode 100644 index 0000000000..3003f3463c --- /dev/null +++ b/src/Paramore.Brighter/InMemoryMessageSchedulerFactory.cs @@ -0,0 +1,32 @@ +using System; + +namespace Paramore.Brighter; + +/// +/// The factory +/// +/// The . +public class InMemoryMessageSchedulerFactory(TimeProvider timerProvider) : IAmAMessageSchedulerFactory +{ + /// + /// Get or create a scheduler id + /// + /// + /// The default approach is to use the message id. + /// + public Func GetOrCreateSchedulerId { get; set; } = message => message.Id; + + /// + /// The action be executed on conflict during scheduler message + /// + public OnSchedulerConflict OnConflict { get; set; } = OnSchedulerConflict.Throw; + + public InMemoryMessageSchedulerFactory() + : this(TimeProvider.System) + { + } + + public IAmAMessageScheduler Create(IAmACommandProcessor processor) + => new InMemoryMessageScheduler(processor, timerProvider, GetOrCreateSchedulerId, OnConflict); +} + diff --git a/src/Paramore.Brighter/InMemoryProducer.cs b/src/Paramore.Brighter/InMemoryProducer.cs index 6b88658f8f..ecd7d989d3 100644 --- a/src/Paramore.Brighter/InMemoryProducer.cs +++ b/src/Paramore.Brighter/InMemoryProducer.cs @@ -56,6 +56,9 @@ public class InMemoryProducer(IAmABus bus, TimeProvider timeProvider) /// public Activity? Span { get; set; } + /// + public IAmAMessageScheduler? Scheduler { get; set; } + /// /// What action should we take on confirmation that a message has been published to a broker /// diff --git a/src/Paramore.Brighter/Message.cs b/src/Paramore.Brighter/Message.cs index 5763a58697..ba48b83a30 100644 --- a/src/Paramore.Brighter/Message.cs +++ b/src/Paramore.Brighter/Message.cs @@ -125,8 +125,8 @@ public Message() [JsonConstructor] public Message(MessageHeader header, MessageBody body) { - Header = header; Body = body; + Header = header; Header.ContentType = string.IsNullOrEmpty(Header.ContentType) ? Body.ContentType: Header.ContentType; } diff --git a/src/Paramore.Brighter/Observability/BrighterSpanExtensions.cs b/src/Paramore.Brighter/Observability/BrighterSpanExtensions.cs index 9bc5e89a22..df9afe05de 100644 --- a/src/Paramore.Brighter/Observability/BrighterSpanExtensions.cs +++ b/src/Paramore.Brighter/Observability/BrighterSpanExtensions.cs @@ -42,6 +42,7 @@ public static class BrighterSpanExtensions CommandProcessorSpanOperation.Send => "send", CommandProcessorSpanOperation.Clear => "clear", CommandProcessorSpanOperation.Archive => "archive", + CommandProcessorSpanOperation.Scheduler => "scheduler", _ => throw new ArgumentOutOfRangeException(nameof(operation), operation, null) }; diff --git a/src/Paramore.Brighter/Observability/CommandProcessorSpanOperation.cs b/src/Paramore.Brighter/Observability/CommandProcessorSpanOperation.cs index 19f29e8b63..2eaa0e8ac8 100644 --- a/src/Paramore.Brighter/Observability/CommandProcessorSpanOperation.cs +++ b/src/Paramore.Brighter/Observability/CommandProcessorSpanOperation.cs @@ -34,5 +34,6 @@ public enum CommandProcessorSpanOperation Publish = 2, // Publish an event Deposit = 3, // Deposit a message in the outbox Clear = 4, // Clear a message from the outbox - Archive = 5 //Archive a message from the outbox + Archive = 5, //Archive a message from the outbox + Scheduler = 6 } diff --git a/src/Paramore.Brighter/OnSchedulerConflict.cs b/src/Paramore.Brighter/OnSchedulerConflict.cs new file mode 100644 index 0000000000..05948e38ac --- /dev/null +++ b/src/Paramore.Brighter/OnSchedulerConflict.cs @@ -0,0 +1,14 @@ +namespace Paramore.Brighter; + +/// +/// Action that should be executed when there is a conflict during create a scheduler +/// +public enum OnSchedulerConflict +{ + Throw, + + /// + /// Overwrite the previous scheduler + /// + Overwrite +} diff --git a/src/Paramore.Brighter/OutboxProducerMediator.cs b/src/Paramore.Brighter/OutboxProducerMediator.cs index 523e283213..a481816f3c 100644 --- a/src/Paramore.Brighter/OutboxProducerMediator.cs +++ b/src/Paramore.Brighter/OutboxProducerMediator.cs @@ -31,6 +31,7 @@ THE SOFTWARE. */ using Microsoft.Extensions.Logging; using Paramore.Brighter.Logging; using Paramore.Brighter.Observability; +using Paramore.Brighter.Scheduler.Events; using Polly; using Polly.Registry; @@ -104,7 +105,7 @@ public OutboxProducerMediator( IAmAMessageMapperRegistry mapperRegistry, IAmAMessageTransformerFactory messageTransformerFactory, IAmAMessageTransformerFactoryAsync messageTransformerFactoryAsync, - IAmABrighterTracer tracer, + IAmABrighterTracer tracer, IAmAnOutbox? outbox = null, IAmARequestContextFactory? requestContextFactory = null, int outboxTimeout = 300, @@ -131,7 +132,7 @@ public OutboxProducerMediator( throw new ConfigurationException( "A Command Processor with an external bus must have a message transformer factory"); - _timeProvider = (timeProvider is null) ? TimeProvider.System : timeProvider; + _timeProvider = timeProvider ?? TimeProvider.System; _lastOutStandingMessageCheckAt = _timeProvider.GetUtcNow(); _transformPipelineBuilder = new TransformPipelineBuilder(mapperRegistry, messageTransformerFactory); @@ -425,6 +426,13 @@ public async Task ClearOutstandingFromOutboxAsync(int amountToClear, public Message CreateMessageFromRequest(TRequest request, RequestContext requestContext) where TRequest : class, IRequest { + // The fired scheduler message is a special case + // Because the message is in the raw form already, just waiting to be fired + if (request is FireSchedulerMessage scheduler) + { + return scheduler.Message; + } + var message = MapMessage(request, requestContext); return message; } @@ -443,7 +451,14 @@ public async Task CreateMessageFromRequestAsync( CancellationToken cancellationToken ) where TRequest : class, IRequest { - Message message = await MapMessageAsync(request, requestContext, cancellationToken); + // The fired scheduler message is a special case + // Because the message is in the raw form already, just waiting to be fired + if (request is FireSchedulerMessage schedulerMessage) + { + return schedulerMessage.Message; + } + + var message = await MapMessageAsync(request, requestContext, cancellationToken); return message; } @@ -748,7 +763,8 @@ private bool ConfigurePublisherCallbackMaybe(IAmAMessageProducer producer, Reque return false; } - private void Dispatch(IEnumerable posts, RequestContext requestContext, + private void Dispatch(IEnumerable posts, + RequestContext requestContext, Dictionary? args = null) { var parentSpan = requestContext.Span; diff --git a/src/Paramore.Brighter/Scheduler/Events/FireSchedulerMessage.cs b/src/Paramore.Brighter/Scheduler/Events/FireSchedulerMessage.cs new file mode 100644 index 0000000000..3c57ed73a4 --- /dev/null +++ b/src/Paramore.Brighter/Scheduler/Events/FireSchedulerMessage.cs @@ -0,0 +1,19 @@ +using System; + +namespace Paramore.Brighter.Scheduler.Events; + +/// +/// A command to fire a scheduler message +/// +public class FireSchedulerMessage() : Command(Guid.NewGuid().ToString()) +{ + /// + /// The message that will be fire + /// + public Message Message { get; init; } = new(); + + /// + /// If it should post sync or async + /// + public bool Async { get; set; } +} diff --git a/tests/Paramore.Brighter.AWS.Tests/Helpers/AWSClientFactory.cs b/tests/Paramore.Brighter.AWS.Tests/Helpers/AWSClientFactory.cs index ac321abff4..123bb7b853 100644 --- a/tests/Paramore.Brighter.AWS.Tests/Helpers/AWSClientFactory.cs +++ b/tests/Paramore.Brighter.AWS.Tests/Helpers/AWSClientFactory.cs @@ -25,6 +25,7 @@ THE SOFTWARE. */ using System; using Amazon; +using Amazon.IdentityManagement; using Amazon.Runtime; using Amazon.S3; using Amazon.SecurityToken; @@ -34,71 +35,19 @@ THE SOFTWARE. */ namespace Paramore.Brighter.AWS.Tests.Helpers; -internal class AWSClientFactory +public static class AWSClientFactoryExtensions { - private readonly AWSCredentials _credentials; - private readonly RegionEndpoint _region; - private readonly Action? _clientConfigAction; - - public AWSClientFactory(AWSMessagingGatewayConnection connection) - { - _credentials = connection.Credentials; - _region = connection.Region; - _clientConfigAction = connection.ClientConfigAction; - } - - public AWSClientFactory(AWSCredentials credentials, RegionEndpoint region, Action? clientConfigAction) + public static AmazonS3Client CreateS3Client(this AWSClientFactory factory) { - _credentials = credentials; - _region = region; - _clientConfigAction = clientConfigAction; + var config = new AmazonS3Config { RegionEndpoint = factory.RegionEndpoint }; + factory.ClientConfigAction?.Invoke(config); + return new AmazonS3Client(factory.Credentials, config); } - public AmazonSimpleNotificationServiceClient CreateSnsClient() + public static AmazonIdentityManagementServiceClient CreateIdentityClient(this AWSClientFactory factory) { - var config = new AmazonSimpleNotificationServiceConfig { RegionEndpoint = _region }; - - if (_clientConfigAction != null) - { - _clientConfigAction(config); - } - - return new AmazonSimpleNotificationServiceClient(_credentials, config); - } - - public AmazonSQSClient CreateSqsClient() - { - var config = new AmazonSQSConfig { RegionEndpoint = _region }; - - if (_clientConfigAction != null) - { - _clientConfigAction(config); - } - - return new AmazonSQSClient(_credentials, config); - } - - public AmazonSecurityTokenServiceClient CreateStsClient() - { - var config = new AmazonSecurityTokenServiceConfig { RegionEndpoint = _region }; - - if (_clientConfigAction != null) - { - _clientConfigAction(config); - } - - return new AmazonSecurityTokenServiceClient(_credentials, config); - } - - public AmazonS3Client CreateS3Client() - { - var config = new AmazonS3Config { RegionEndpoint = _region }; - - if (_clientConfigAction != null) - { - _clientConfigAction(config); - } - - return new AmazonS3Client(_credentials, config); + var config = new AmazonIdentityManagementServiceConfig { RegionEndpoint = factory.RegionEndpoint }; + factory.ClientConfigAction?.Invoke(config); + return new AmazonIdentityManagementServiceClient(factory.Credentials, config); } } diff --git a/tests/Paramore.Brighter.AWS.Tests/Helpers/GatewayFactory.cs b/tests/Paramore.Brighter.AWS.Tests/Helpers/GatewayFactory.cs index 76cc08cf87..48575c4897 100644 --- a/tests/Paramore.Brighter.AWS.Tests/Helpers/GatewayFactory.cs +++ b/tests/Paramore.Brighter.AWS.Tests/Helpers/GatewayFactory.cs @@ -30,11 +30,11 @@ public static AWSMessagingGatewayConnection CreateFactory( { config?.Invoke(cfg); - var serviceURL = Environment.GetEnvironmentVariable("LOCALSTACK_SERVICE_URL"); + /*var serviceURL = Environment.GetEnvironmentVariable("LOCALSTACK_SERVICE_URL"); if (!string.IsNullOrWhiteSpace(serviceURL)) { cfg.ServiceURL = serviceURL; - } + }*/ }); } } diff --git a/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message.cs b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message.cs new file mode 100644 index 0000000000..6d645f5a86 --- /dev/null +++ b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message.cs @@ -0,0 +1,103 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Paramore.Brighter.AWS.Tests.Helpers; +using Paramore.Brighter.AWS.Tests.TestDoubles; +using Paramore.Brighter.MessageScheduler.Aws; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Xunit; + +namespace Paramore.Brighter.AWS.Tests.MessageScheduler.Sns; + +public class SnsSchedulingMessageTest : IDisposable +{ + private const string ContentType = "text\\plain"; + private const int BufferSize = 3; + private readonly SnsMessageProducer _messageProducer; + private readonly SqsMessageConsumer _consumer; + private readonly string _topicName; + private readonly ChannelFactory _channelFactory; + private readonly IAmAMessageSchedulerFactory _factory; + + public SnsSchedulingMessageTest() + { + var awsConnection = GatewayFactory.CreateFactory(); + + _channelFactory = new ChannelFactory(awsConnection); + //we need the channel to create the queues and notifications + _topicName = $"Producer-Scheduler-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + var channelName = $"Producer-Scheduler-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + var routingKey = new RoutingKey(_topicName); + + var channel = _channelFactory.CreateSyncChannel(new SqsSubscription( + name: new SubscriptionName(channelName), + channelName: new ChannelName(channelName), + routingKey: routingKey, + bufferSize: BufferSize, + makeChannels: OnMissingChannel.Create + )); + + //we want to access via a consumer, to receive multiple messages - we don't want to expose on channel + //just for the tests, so create a new consumer from the properties + _consumer = new SqsMessageConsumer(awsConnection, channel.Name.ToValidSQSQueueName(), BufferSize); + _messageProducer = new SnsMessageProducer(awsConnection, + new SnsPublication { MakeChannels = OnMissingChannel.Create }); + + // Enforce topic to be created + _messageProducer.Send(new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + )); + + _consumer.Purge(); + + _factory = new AwsMessageSchedulerFactory(awsConnection, "brighter-scheduler") + { + UseMessageTopicAsTarget = true, MakeRole = OnMissingRole.CreateRole + }; + } + + [Fact] + public void When_Scheduling_A_Sns_Message() + { + var routingKey = new RoutingKey(_topicName); + var message = new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + ); + + var scheduler = (IAmAMessageSchedulerSync)_factory.Create(null!); + scheduler.Schedule(message, TimeSpan.FromMinutes(1)); + + Task.Delay(TimeSpan.FromMinutes(1)).Wait(); + + var stopAt = DateTimeOffset.UtcNow.AddMinutes(2); + while (stopAt > DateTimeOffset.UtcNow) + { + var messages = _consumer.Receive(); + messages.Should().ContainSingle(); + + if (messages[0].Header.MessageType != MessageType.MT_NONE) + { + messages[0].Body.Value.Should().Be(message.Body.Value); + messages[0].Header.Should().BeEquivalentTo(message.Header); + _consumer.Acknowledge(messages[0]); + return; + } + + Task.Delay(TimeSpan.FromSeconds(1)).Wait(); + } + + Assert.Fail("The message wasn't fired"); + } + + public void Dispose() + { + _channelFactory.DeleteQueueAsync().GetAwaiter().GetResult(); + _channelFactory.DeleteTopicAsync().GetAwaiter().GetResult(); + _messageProducer.Dispose(); + _consumer.Dispose(); + } +} diff --git a/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message_Async.cs b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message_Async.cs new file mode 100644 index 0000000000..cfc5bbf830 --- /dev/null +++ b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message_Async.cs @@ -0,0 +1,102 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Paramore.Brighter.AWS.Tests.Helpers; +using Paramore.Brighter.AWS.Tests.TestDoubles; +using Paramore.Brighter.MessageScheduler.Aws; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Xunit; + +namespace Paramore.Brighter.AWS.Tests.MessageScheduler.Sns; + +public class SnsSchedulingAsyncMessageTest : IAsyncDisposable +{ + private const string ContentType = "text\\plain"; + private const int BufferSize = 3; + private readonly SnsMessageProducer _messageProducer; + private readonly SqsMessageConsumer _consumer; + private readonly string _topicName; + private readonly ChannelFactory _channelFactory; + private readonly IAmAMessageSchedulerFactory _factory; + + public SnsSchedulingAsyncMessageTest() + { + var awsConnection = GatewayFactory.CreateFactory(); + + _channelFactory = new ChannelFactory(awsConnection); + //we need the channel to create the queues and notifications + _topicName = $"Producer-Scheduler-Async-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + var channelName = $"Producer-Scheduler-Async-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + var routingKey = new RoutingKey(_topicName); + + var channel = _channelFactory.CreateAsyncChannelAsync(new SqsSubscription( + name: new SubscriptionName(channelName), + channelName: new ChannelName(channelName), + routingKey: routingKey, + bufferSize: BufferSize, + makeChannels: OnMissingChannel.Create + )).GetAwaiter().GetResult(); + + //we want to access via a consumer, to receive multiple messages - we don't want to expose on channel + //just for the tests, so create a new consumer from the properties + _consumer = new SqsMessageConsumer(awsConnection, channel.Name.ToValidSQSQueueName(), BufferSize); + _messageProducer = new SnsMessageProducer(awsConnection, + new SnsPublication { MakeChannels = OnMissingChannel.Create }); + + // Enforce topic to be created + _messageProducer.SendAsync(new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + )).GetAwaiter().GetResult(); + + _consumer.Purge(); + + _factory = new AwsMessageSchedulerFactory(awsConnection, "brighter-scheduler") + { + UseMessageTopicAsTarget = true, + MakeRole = OnMissingRole.CreateRole + }; + } + + [Fact] + public async Task When_Scheduling_A_Sns_Message_Async() + { + var routingKey = new RoutingKey(_topicName); + var message = new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + ); + + var scheduler = (IAmAMessageSchedulerAsync)_factory.Create(null!); + await scheduler.ScheduleAsync(message, TimeSpan.FromMinutes(1)); + await Task.Delay(TimeSpan.FromMinutes(1)); + var stopAt = DateTimeOffset.UtcNow.AddMinutes(2); + while (stopAt > DateTimeOffset.UtcNow) + { + var messages = await _consumer.ReceiveAsync(TimeSpan.FromMinutes(1)); + messages.Should().ContainSingle(); + + if (messages[0].Header.MessageType != MessageType.MT_NONE) + { + messages[0].Body.Value.Should().Be(message.Body.Value); + messages[0].Header.Should().BeEquivalentTo(message.Header, opt => opt.Excluding(x => x.Bag)); + await _consumer.AcknowledgeAsync(messages[0]); + return; + } + + await Task.Delay(TimeSpan.FromSeconds(1)); + } + + Assert.Fail("The message wasn't fired"); + } + + public async ValueTask DisposeAsync() + { + await _channelFactory.DeleteQueueAsync(); + await _channelFactory.DeleteTopicAsync(); + await _messageProducer.DisposeAsync(); + await _consumer.DisposeAsync(); + } +} diff --git a/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message_Via_FireScheduler.cs b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message_Via_FireScheduler.cs new file mode 100644 index 0000000000..c20040dfde --- /dev/null +++ b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message_Via_FireScheduler.cs @@ -0,0 +1,108 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Paramore.Brighter.AWS.Tests.Helpers; +using Paramore.Brighter.MessageScheduler.Aws; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Paramore.Brighter.Scheduler.Events; +using Xunit; + +namespace Paramore.Brighter.AWS.Tests.MessageScheduler.Sns; + +public class SnsSchedulingMessageViaFireSchedulerTest : IDisposable +{ + private const string ContentType = "text\\plain"; + private const int BufferSize = 3; + private readonly SnsMessageProducer _messageProducer; + private readonly SqsMessageConsumer _consumer; + private readonly string _topicName; + private readonly ChannelFactory _channelFactory; + private readonly IAmAMessageSchedulerFactory _factory; + + public SnsSchedulingMessageViaFireSchedulerTest() + { + var awsConnection = GatewayFactory.CreateFactory(); + + _channelFactory = new ChannelFactory(awsConnection); + //we need the channel to create the queues and notifications + _topicName = $"Producer-Fire-Scheduler-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + var channelName = $"Producer-Fire-Scheduler-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + var routingKey = new RoutingKey(_topicName); + + var channel = _channelFactory.CreateSyncChannel(new SqsSubscription( + name: new SubscriptionName(channelName), + channelName: new ChannelName(channelName), + routingKey: routingKey, + bufferSize: BufferSize, + makeChannels: OnMissingChannel.Create + )); + + //we want to access via a consumer, to receive multiple messages - we don't want to expose on channel + //just for the tests, so create a new consumer from the properties + _consumer = new SqsMessageConsumer(awsConnection, channel.Name.ToValidSQSQueueName(), BufferSize); + _messageProducer = + new SnsMessageProducer(awsConnection, new SnsPublication { MakeChannels = OnMissingChannel.Create }); + + // Enforce topic to be created + _messageProducer.Send(new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + )); + _consumer.Purge(); + + _factory = new AwsMessageSchedulerFactory(awsConnection, "brighter-scheduler") + { + UseMessageTopicAsTarget = false, MakeRole = OnMissingRole.CreateRole, SchedulerTopicOrQueue = routingKey + }; + } + + [Fact] + public void When_Scheduling_A_Sns_Message_With_Delay_Via_FireScheduler() + { + var routingKey = new RoutingKey(_topicName); + var message = new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + ); + + var scheduler = (IAmAMessageSchedulerSync)_factory.Create(null!); + scheduler.Schedule(message, TimeSpan.FromMinutes(1)); + + Task.Delay(TimeSpan.FromMinutes(1)).Wait(); + + var stopAt = DateTimeOffset.UtcNow.AddMinutes(2); + while (stopAt > DateTimeOffset.UtcNow) + { + var messages = _consumer.Receive(TimeSpan.FromMinutes(1)); + messages.Should().ContainSingle(); + + if (messages[0].Header.MessageType != MessageType.MT_NONE) + { + messages[0].Header.MessageType.Should().Be(MessageType.MT_COMMAND); + messages[0].Body.Value.Should().NotBeNullOrEmpty(); + var m = JsonSerializer.Deserialize(messages[0].Body.Value, + JsonSerialisationOptions.Options); + m.Should().NotBeNull(); + m.Message.Should().BeEquivalentTo(message); + m.Async.Should().BeFalse(); + _consumer.Acknowledge(messages[0]); + return; + } + + Task.Delay(TimeSpan.FromSeconds(1)).Wait(); + } + + Assert.Fail("The message wasn't fired"); + } + + public void Dispose() + { + _channelFactory.DeleteQueueAsync().GetAwaiter().GetResult(); + _channelFactory.DeleteTopicAsync().GetAwaiter().GetResult(); + _messageProducer.Dispose(); + _consumer.Dispose(); + } +} diff --git a/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message_Via_FireScheduler_Async.cs b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message_Via_FireScheduler_Async.cs new file mode 100644 index 0000000000..55d05af70f --- /dev/null +++ b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sns/When_Scheduling_A_Sns_Message_Via_FireScheduler_Async.cs @@ -0,0 +1,108 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Paramore.Brighter.AWS.Tests.Helpers; +using Paramore.Brighter.MessageScheduler.Aws; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Paramore.Brighter.Scheduler.Events; +using Xunit; + +namespace Paramore.Brighter.AWS.Tests.MessageScheduler.Sns; + +public class SnsSchedulingMessageViaFireSchedulerAsyncTest : IDisposable +{ + private const string ContentType = "text\\plain"; + private const int BufferSize = 3; + private readonly SnsMessageProducer _messageProducer; + private readonly SqsMessageConsumer _consumer; + private readonly string _topicName; + private readonly ChannelFactory _channelFactory; + private readonly IAmAMessageSchedulerFactory _factory; + + public SnsSchedulingMessageViaFireSchedulerAsyncTest() + { + var awsConnection = GatewayFactory.CreateFactory(); + + _channelFactory = new ChannelFactory(awsConnection); + //we need the channel to create the queues and notifications + _topicName = $"Producer-Fire-Scheduler-Async-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + var channelName = $"Producer-Fire-Scheduler-Async-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + var routingKey = new RoutingKey(_topicName); + + var channel = _channelFactory.CreateAsyncChannel(new SqsSubscription( + name: new SubscriptionName(channelName), + channelName: new ChannelName(channelName), + routingKey: routingKey, + bufferSize: BufferSize, + makeChannels: OnMissingChannel.Create + )); + + //we want to access via a consumer, to receive multiple messages - we don't want to expose on channel + //just for the tests, so create a new consumer from the properties + _consumer = new SqsMessageConsumer(awsConnection, channel.Name.ToValidSQSQueueName(), BufferSize); + _messageProducer = + new SnsMessageProducer(awsConnection, new SnsPublication { MakeChannels = OnMissingChannel.Create }); + + // Enforce topic to be created + _messageProducer.Send(new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + )); + _consumer.Purge(); + + _factory = new AwsMessageSchedulerFactory(awsConnection, "brighter-scheduler") + { + UseMessageTopicAsTarget = false, MakeRole = OnMissingRole.CreateRole, SchedulerTopicOrQueue = routingKey + }; + } + + [Fact] + public async Task When_Scheduling_A_Sns_Message_Async() + { + var routingKey = new RoutingKey(_topicName); + var message = new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + ); + + var scheduler = (IAmAMessageSchedulerAsync)_factory.Create(null!); + await scheduler.ScheduleAsync(message, TimeSpan.FromMinutes(1)); + + await Task.Delay(TimeSpan.FromMinutes(1)); + + var stopAt = DateTimeOffset.UtcNow.AddMinutes(2); + while (stopAt > DateTimeOffset.UtcNow) + { + var messages = await _consumer.ReceiveAsync(); + messages.Should().ContainSingle(); + + if (messages[0].Header.MessageType != MessageType.MT_NONE) + { + messages[0].Header.MessageType.Should().Be(MessageType.MT_COMMAND); + messages[0].Body.Value.Should().NotBeNullOrEmpty(); + var m = JsonSerializer.Deserialize(messages[0].Body.Value, + JsonSerialisationOptions.Options); + m.Should().NotBeNull(); + m.Message.Should().BeEquivalentTo(message); + m.Async.Should().BeTrue(); + await _consumer.AcknowledgeAsync(messages[0]); + return; + } + + await Task.Delay(TimeSpan.FromSeconds(1)); + } + + Assert.Fail("The message wasn't fired"); + } + + public void Dispose() + { + _channelFactory.DeleteQueueAsync().GetAwaiter().GetResult(); + _channelFactory.DeleteTopicAsync().GetAwaiter().GetResult(); + _messageProducer.Dispose(); + _consumer.Dispose(); + } +} diff --git a/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message.cs b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message.cs new file mode 100644 index 0000000000..1b454e136d --- /dev/null +++ b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message.cs @@ -0,0 +1,95 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Paramore.Brighter.AWS.Tests.Helpers; +using Paramore.Brighter.AWS.Tests.TestDoubles; +using Paramore.Brighter.MessageScheduler.Aws; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Xunit; + +namespace Paramore.Brighter.AWS.Tests.MessageScheduler.Sqs; + +public class SqsSchedulingMessageTest : IDisposable +{ + private const string ContentType = "text\\plain"; + private const int BufferSize = 3; + private readonly SqsMessageProducer _messageProducer; + private readonly SqsMessageConsumer _consumer; + private readonly string _queueName; + private readonly ChannelFactory _channelFactory; + private readonly IAmAMessageSchedulerFactory _factory; + + public SqsSchedulingMessageTest() + { + var awsConnection = GatewayFactory.CreateFactory(); + + _channelFactory = new ChannelFactory(awsConnection); + var subscriptionName = $"Buffered-Scheduler-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + _queueName = $"Buffered-Scheduler-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + + //we need the channel to create the queues and notifications + var routingKey = new RoutingKey(_queueName); + + var channel = _channelFactory.CreateSyncChannel(new SqsSubscription( + name: new SubscriptionName(subscriptionName), + channelName: new ChannelName(_queueName), + routingKey: routingKey, + bufferSize: BufferSize, + makeChannels: OnMissingChannel.Create, + channelType: ChannelType.PointToPoint + )); + + //we want to access via a consumer, to receive multiple messages - we don't want to expose on channel + //just for the tests, so create a new consumer from the properties + _consumer = new SqsMessageConsumer(awsConnection, channel.Name.ToValidSQSQueueName(), BufferSize); + _messageProducer = new SqsMessageProducer(awsConnection, + new SqsPublication { MakeChannels = OnMissingChannel.Create }); + + _factory = new AwsMessageSchedulerFactory(awsConnection, "brighter-scheduler") + { + UseMessageTopicAsTarget = true, MakeRole = OnMissingRole.CreateRole + }; + } + + [Fact] + public void When_Scheduling_A_Sqs_Message() + { + var routingKey = new RoutingKey(_queueName); + var message = new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + ); + + var scheduler = (IAmAMessageSchedulerSync)_factory.Create(null!)!; + scheduler.Schedule(message, TimeSpan.FromMinutes(1)); + + Task.Delay(TimeSpan.FromMinutes(1)).Wait(); + + var stopAt = DateTimeOffset.UtcNow.AddMinutes(2); + while (stopAt > DateTimeOffset.UtcNow) + { + var messages = _consumer.Receive(TimeSpan.FromMinutes(1)); + messages.Should().ContainSingle(); + + if (messages[0].Header.MessageType != MessageType.MT_NONE) + { + messages[0].Body.Value.Should().Be(message.Body.Value); + messages[0].Header.Should().BeEquivalentTo(message.Header); + _consumer.Acknowledge(messages[0]); + return; + } + + Task.Delay(TimeSpan.FromSeconds(1)).Wait(); + } + + Assert.Fail("The message wasn't fired"); + } + + public void Dispose() + { + _channelFactory.DeleteQueueAsync().GetAwaiter().GetResult(); + _messageProducer.Dispose(); + _consumer.Dispose(); + } +} diff --git a/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message_Async.cs b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message_Async.cs new file mode 100644 index 0000000000..3bc6b8b1a0 --- /dev/null +++ b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message_Async.cs @@ -0,0 +1,96 @@ +using System; +using System.Threading.Tasks; +using FluentAssertions; +using Paramore.Brighter.AWS.Tests.Helpers; +using Paramore.Brighter.AWS.Tests.TestDoubles; +using Paramore.Brighter.MessageScheduler.Aws; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Xunit; + +namespace Paramore.Brighter.AWS.Tests.MessageScheduler.Sqs; + +public class SqsSchedulingAsyncMessageTest : IAsyncDisposable +{ + private const string ContentType = "text\\plain"; + private const int BufferSize = 3; + private readonly SqsMessageProducer _messageProducer; + private readonly SqsMessageConsumer _consumer; + private readonly string _queueName; + private readonly ChannelFactory _channelFactory; + private readonly IAmAMessageSchedulerFactory _factory; + + public SqsSchedulingAsyncMessageTest() + { + var awsConnection = GatewayFactory.CreateFactory(); + + _channelFactory = new ChannelFactory(awsConnection); + var subscriptionName = $"Buffered-Scheduler-Async-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + _queueName = $"Buffered-Scheduler-Async-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + + //we need the channel to create the queues and notifications + var routingKey = new RoutingKey(_queueName); + + var channel = _channelFactory.CreateAsyncChannelAsync(new SqsSubscription( + name: new SubscriptionName(subscriptionName), + channelName: new ChannelName(_queueName), + routingKey: routingKey, + bufferSize: BufferSize, + makeChannels: OnMissingChannel.Create, + channelType: ChannelType.PointToPoint + )).GetAwaiter().GetResult(); + + //we want to access via a consumer, to receive multiple messages - we don't want to expose on channel + //just for the tests, so create a new consumer from the properties + _consumer = new SqsMessageConsumer(awsConnection, channel.Name.ToValidSQSQueueName(), BufferSize); + _messageProducer = new SqsMessageProducer(awsConnection, + new SqsPublication { MakeChannels = OnMissingChannel.Create }); + + _factory = new AwsMessageSchedulerFactory(awsConnection, "brighter-scheduler") + { + UseMessageTopicAsTarget = true + }; + } + + [Fact] + public async Task When_Scheduling_A_Sqs_Message_Async() + { + var routingKey = new RoutingKey(_queueName); + var message = new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + ); + + var scheduler = (IAmAMessageSchedulerAsync)_factory.Create(null!)!; + await scheduler.ScheduleAsync(message, TimeSpan.FromMinutes(1)); + + await Task.Delay(TimeSpan.FromMinutes(1)); + + var stopAt = DateTimeOffset.UtcNow.AddMinutes(2); + while (stopAt > DateTimeOffset.UtcNow) + { + var messages = await _consumer.ReceiveAsync(TimeSpan.FromMinutes(1)); + messages.Should().ContainSingle(); + + if (messages[0].Header.MessageType != MessageType.MT_NONE) + { + messages[0].Body.Value.Should().Be(message.Body.Value); + messages[0].Header.Should().BeEquivalentTo(message.Header); + await _consumer.AcknowledgeAsync(messages[0]); + return; + } + + + await Task.Delay(TimeSpan.FromSeconds(1)); + } + + Assert.Fail("The message wasn't fired"); + } + + public async ValueTask DisposeAsync() + { + await _channelFactory.DeleteQueueAsync(); + await _messageProducer.DisposeAsync(); + await _consumer.DisposeAsync(); + } +} diff --git a/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message_Via_FireScheduler.cs b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message_Via_FireScheduler.cs new file mode 100644 index 0000000000..5c61750ec6 --- /dev/null +++ b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message_Via_FireScheduler.cs @@ -0,0 +1,105 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Paramore.Brighter.AWS.Tests.Helpers; +using Paramore.Brighter.AWS.Tests.TestDoubles; +using Paramore.Brighter.MessageScheduler.Aws; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Paramore.Brighter.Scheduler.Events; +using Xunit; + +namespace Paramore.Brighter.AWS.Tests.MessageScheduler.Sqs; + +[Collection("Scheduler SQS")] +public class SqsSchedulingMessageViaFireSchedulerTest : IDisposable +{ + private const string ContentType = "text\\plain"; + private const int BufferSize = 3; + private readonly SqsMessageProducer _messageProducer; + private readonly SqsMessageConsumer _consumer; + private readonly string _queueName; + private readonly ChannelFactory _channelFactory; + private readonly IAmAMessageSchedulerFactory _factory; + + public SqsSchedulingMessageViaFireSchedulerTest() + { + var awsConnection = GatewayFactory.CreateFactory(); + + _channelFactory = new ChannelFactory(awsConnection); + var subscriptionName = $"Buffered-Scheduler-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + _queueName = $"Buffered-Scheduler-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + + //we need the channel to create the queues and notifications + var routingKey = new RoutingKey(_queueName); + + var channel = _channelFactory.CreateSyncChannel(new SqsSubscription( + name: new SubscriptionName(subscriptionName), + channelName: new ChannelName(_queueName), + routingKey: routingKey, + bufferSize: BufferSize, + makeChannels: OnMissingChannel.Create, + channelType: ChannelType.PointToPoint + )); + + //we want to access via a consumer, to receive multiple messages - we don't want to expose on channel + //just for the tests, so create a new consumer from the properties + _consumer = new SqsMessageConsumer(awsConnection, channel.Name.ToValidSQSQueueName(), BufferSize); + _messageProducer = new SqsMessageProducer(awsConnection, + new SqsPublication { MakeChannels = OnMissingChannel.Create }); + + _factory = new AwsMessageSchedulerFactory(awsConnection, "brighter-scheduler") + { + UseMessageTopicAsTarget = false, + MakeRole = OnMissingRole.CreateRole, + SchedulerTopicOrQueue = routingKey + }; + } + + [Fact] + public void When_Scheduling_A_Sqs_Message_Via_FireScheduler() + { + var routingKey = new RoutingKey(_queueName); + var message = new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + ); + + var scheduler = (IAmAMessageSchedulerSync)_factory.Create(null!)!; + scheduler.Schedule(message, TimeSpan.FromMinutes(1)); + + Task.Delay(TimeSpan.FromMinutes(1)).Wait(); + + var stopAt = DateTimeOffset.UtcNow.AddMinutes(2); + while (stopAt > DateTimeOffset.UtcNow) + { + var messages = _consumer.Receive(TimeSpan.FromMinutes(1)); + messages.Should().ContainSingle(); + + if (messages[0].Header.MessageType != MessageType.MT_NONE) + { + messages[0].Header.MessageType.Should().Be(MessageType.MT_COMMAND); + messages[0].Body.Value.Should().NotBeNullOrEmpty(); + var m = JsonSerializer.Deserialize(messages[0].Body.Value, + JsonSerialisationOptions.Options); + m.Should().NotBeNull(); + m.Message.Should().BeEquivalentTo(message); + m.Async.Should().BeFalse(); + _consumer.Acknowledge(messages[0]); + return; + } + + Task.Delay(TimeSpan.FromSeconds(1)).Wait(); + } + + Assert.Fail("The message wasn't fired"); + } + + public void Dispose() + { + _channelFactory.DeleteQueueAsync().GetAwaiter().GetResult(); + _messageProducer.Dispose(); + _consumer.Dispose(); + } +} diff --git a/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message_Via_FireScheduler_Async.cs b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message_Via_FireScheduler_Async.cs new file mode 100644 index 0000000000..8de54d3c9d --- /dev/null +++ b/tests/Paramore.Brighter.AWS.Tests/MessageScheduler/Sqs/When_Scheduling_A_Sqs_Message_Via_FireScheduler_Async.cs @@ -0,0 +1,103 @@ +using System; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Paramore.Brighter.AWS.Tests.Helpers; +using Paramore.Brighter.AWS.Tests.TestDoubles; +using Paramore.Brighter.MessageScheduler.Aws; +using Paramore.Brighter.MessagingGateway.AWSSQS; +using Paramore.Brighter.Scheduler.Events; +using Xunit; + +namespace Paramore.Brighter.AWS.Tests.MessageScheduler.Sqs; + +public class SqsSchedulingAsyncMessageViaFireSchedulerTest : IAsyncDisposable +{ + private const string ContentType = "text\\plain"; + private const int BufferSize = 3; + private readonly SqsMessageProducer _messageProducer; + private readonly SqsMessageConsumer _consumer; + private readonly string _queueName; + private readonly ChannelFactory _channelFactory; + private readonly IAmAMessageSchedulerFactory _factory; + + public SqsSchedulingAsyncMessageViaFireSchedulerTest() + { + var awsConnection = GatewayFactory.CreateFactory(); + + _channelFactory = new ChannelFactory(awsConnection); + var subscriptionName = $"Buffered-Scheduler-Async-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + _queueName = $"Buffered-Scheduler-Async-Tests-{Guid.NewGuid().ToString()}".Truncate(45); + + //we need the channel to create the queues and notifications + var routingKey = new RoutingKey(_queueName); + + var channel = _channelFactory.CreateAsyncChannelAsync(new SqsSubscription( + name: new SubscriptionName(subscriptionName), + channelName: new ChannelName(_queueName), + routingKey: routingKey, + bufferSize: BufferSize, + makeChannels: OnMissingChannel.Create, + channelType: ChannelType.PointToPoint + )).GetAwaiter().GetResult(); + + //we want to access via a consumer, to receive multiple messages - we don't want to expose on channel + //just for the tests, so create a new consumer from the properties + _consumer = new SqsMessageConsumer(awsConnection, channel.Name.ToValidSQSQueueName(), BufferSize); + _messageProducer = new SqsMessageProducer(awsConnection, + new SqsPublication { MakeChannels = OnMissingChannel.Create }); + + _factory = new AwsMessageSchedulerFactory(awsConnection, "brighter-scheduler") + { + UseMessageTopicAsTarget = false, MakeRole = OnMissingRole.CreateRole, SchedulerTopicOrQueue = routingKey + }; + } + + [Fact] + public async Task When_Scheduling_A_Sqs_Message_Via_FireScheduler_Async() + { + var routingKey = new RoutingKey(_queueName); + var message = new Message( + new MessageHeader(Guid.NewGuid().ToString(), routingKey, MessageType.MT_COMMAND, + correlationId: Guid.NewGuid().ToString(), contentType: ContentType), + new MessageBody("test content one") + ); + + var scheduler = (IAmAMessageSchedulerAsync)_factory.Create(null!)!; + await scheduler.ScheduleAsync(message, TimeSpan.FromMinutes(1)); + + await Task.Delay(TimeSpan.FromMinutes(1)); + + var stopAt = DateTimeOffset.UtcNow.AddMinutes(2); + while (stopAt > DateTimeOffset.UtcNow) + { + var messages = await _consumer.ReceiveAsync(TimeSpan.FromMinutes(1)); + messages.Should().ContainSingle(); + + if (messages[0].Header.MessageType != MessageType.MT_NONE) + { + messages[0].Header.MessageType.Should().Be(MessageType.MT_COMMAND); + messages[0].Body.Value.Should().NotBeNullOrEmpty(); + var m = JsonSerializer.Deserialize(messages[0].Body.Value, + JsonSerialisationOptions.Options); + m.Should().NotBeNull(); + m.Message.Should().BeEquivalentTo(message); + m.Async.Should().BeTrue(); + await _consumer.AcknowledgeAsync(messages[0]); + return; + } + + + await Task.Delay(TimeSpan.FromSeconds(1)); + } + + Assert.Fail("The message wasn't fired"); + } + + public async ValueTask DisposeAsync() + { + await _channelFactory.DeleteQueueAsync(); + await _messageProducer.DisposeAsync(); + await _consumer.DisposeAsync(); + } +} diff --git a/tests/Paramore.Brighter.AWS.Tests/Paramore.Brighter.AWS.Tests.csproj b/tests/Paramore.Brighter.AWS.Tests/Paramore.Brighter.AWS.Tests.csproj index 6efd2fd56d..35660df737 100644 --- a/tests/Paramore.Brighter.AWS.Tests/Paramore.Brighter.AWS.Tests.csproj +++ b/tests/Paramore.Brighter.AWS.Tests/Paramore.Brighter.AWS.Tests.csproj @@ -6,6 +6,7 @@ + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -22,6 +23,7 @@ + diff --git a/tests/Paramore.Brighter.AWS.Tests/Transformers/When_creating_luggagestore_missing_parameters.cs b/tests/Paramore.Brighter.AWS.Tests/Transformers/When_creating_luggagestore_missing_parameters.cs index dc1dd5e49f..12be82fbc3 100644 --- a/tests/Paramore.Brighter.AWS.Tests/Transformers/When_creating_luggagestore_missing_parameters.cs +++ b/tests/Paramore.Brighter.AWS.Tests/Transformers/When_creating_luggagestore_missing_parameters.cs @@ -2,14 +2,13 @@ using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; -using Amazon; -using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; using Amazon.SecurityToken; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter.AWS.Tests.Helpers; +using Paramore.Brighter.MessagingGateway.AWSSQS; using Paramore.Brighter.Tranformers.AWS; using Xunit; diff --git a/tests/Paramore.Brighter.AWS.Tests/Transformers/When_unwrapping_a_large_message.cs b/tests/Paramore.Brighter.AWS.Tests/Transformers/When_unwrapping_a_large_message.cs index e8643ac374..d030846068 100644 --- a/tests/Paramore.Brighter.AWS.Tests/Transformers/When_unwrapping_a_large_message.cs +++ b/tests/Paramore.Brighter.AWS.Tests/Transformers/When_unwrapping_a_large_message.cs @@ -4,15 +4,13 @@ using System.Net.Http; using System.Text.Json; using System.Threading.Tasks; -using Amazon; -using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; -using Amazon.SecurityToken; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter.AWS.Tests.Helpers; using Paramore.Brighter.AWS.Tests.TestDoubles; +using Paramore.Brighter.MessagingGateway.AWSSQS; using Paramore.Brighter.Tranformers.AWS; using Paramore.Brighter.Transforms.Transformers; using Xunit; diff --git a/tests/Paramore.Brighter.AWS.Tests/Transformers/When_uploading_luggage_to_S3.cs b/tests/Paramore.Brighter.AWS.Tests/Transformers/When_uploading_luggage_to_S3.cs index b33494b163..7eecea233f 100644 --- a/tests/Paramore.Brighter.AWS.Tests/Transformers/When_uploading_luggage_to_S3.cs +++ b/tests/Paramore.Brighter.AWS.Tests/Transformers/When_uploading_luggage_to_S3.cs @@ -4,14 +4,13 @@ using System.Net; using System.Net.Http; using System.Threading.Tasks; -using Amazon; -using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; using Amazon.SecurityToken; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter.AWS.Tests.Helpers; +using Paramore.Brighter.MessagingGateway.AWSSQS; using Paramore.Brighter.Tranformers.AWS; using Polly; using Polly.Contrib.WaitAndRetry; diff --git a/tests/Paramore.Brighter.AWS.Tests/Transformers/When_validating_a_luggage_store_exists.cs b/tests/Paramore.Brighter.AWS.Tests/Transformers/When_validating_a_luggage_store_exists.cs index 5efa46e871..d0e30d2965 100644 --- a/tests/Paramore.Brighter.AWS.Tests/Transformers/When_validating_a_luggage_store_exists.cs +++ b/tests/Paramore.Brighter.AWS.Tests/Transformers/When_validating_a_luggage_store_exists.cs @@ -1,23 +1,16 @@ using System; using System.Collections.Generic; -using System.IO; -using System.Net; using System.Net.Http; using System.Threading.Tasks; -using Amazon; -using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; using Amazon.SecurityToken; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter.AWS.Tests.Helpers; +using Paramore.Brighter.MessagingGateway.AWSSQS; using Paramore.Brighter.Tranformers.AWS; -using Polly; -using Polly.Contrib.WaitAndRetry; -using Polly.Retry; using Xunit; -using Policy = Polly.Policy; namespace Paramore.Brighter.AWS.Tests.Transformers; diff --git a/tests/Paramore.Brighter.AWS.Tests/Transformers/When_wrapping_a_large_message.cs b/tests/Paramore.Brighter.AWS.Tests/Transformers/When_wrapping_a_large_message.cs index fa0ab9db5b..057f027a53 100644 --- a/tests/Paramore.Brighter.AWS.Tests/Transformers/When_wrapping_a_large_message.cs +++ b/tests/Paramore.Brighter.AWS.Tests/Transformers/When_wrapping_a_large_message.cs @@ -1,16 +1,13 @@ using System; -using System.Collections.Generic; using System.Net.Http; using System.Threading.Tasks; -using Amazon; -using Amazon.Runtime; using Amazon.S3; using Amazon.S3.Model; -using Amazon.SecurityToken; using FluentAssertions; using Microsoft.Extensions.DependencyInjection; using Paramore.Brighter.AWS.Tests.Helpers; using Paramore.Brighter.AWS.Tests.TestDoubles; +using Paramore.Brighter.MessagingGateway.AWSSQS; using Paramore.Brighter.Tranformers.AWS; using Paramore.Brighter.Transforms.Transformers; using Xunit; diff --git a/tests/Paramore.Brighter.AzureServiceBus.Tests/Fakes/FakeMessageProducer.cs b/tests/Paramore.Brighter.AzureServiceBus.Tests/Fakes/FakeMessageProducer.cs index df947fb31f..c377060699 100644 --- a/tests/Paramore.Brighter.AzureServiceBus.Tests/Fakes/FakeMessageProducer.cs +++ b/tests/Paramore.Brighter.AzureServiceBus.Tests/Fakes/FakeMessageProducer.cs @@ -11,6 +11,8 @@ public class FakeMessageProducer : IAmAMessageProducerAsync, IAmAMessageProducer public List SentMessages { get; } = new List(); public Publication Publication { get; } public Activity Span { get; set; } + public IAmAMessageScheduler? Scheduler { get; set; } + public Task SendAsync(Message message, CancellationToken cancellationToken = default) { Send(message); diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_A_Message_To_The_Command_Processor.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_A_Message_To_The_Command_Processor.cs index 9735e800b3..719b22a924 100644 --- a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_A_Message_To_The_Command_Processor.cs +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_A_Message_To_The_Command_Processor.cs @@ -99,7 +99,7 @@ public CommandProcessorPostCommandTests() policyRegistry, bus ); - } + } [Fact] public void When_Posting_A_Message_To_The_Command_Processor() diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_Fails_Limit_Total_Writes_To_OutBox_In_Window.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_Fails_Limit_Total_Writes_To_OutBox_In_Window.cs index 871ee78e84..dffe70b735 100644 --- a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_Fails_Limit_Total_Writes_To_OutBox_In_Window.cs +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_Fails_Limit_Total_Writes_To_OutBox_In_Window.cs @@ -79,6 +79,7 @@ public PostFailureLimitCommandTests() .ExternalBus(ExternalBusType.FireAndForget, externalBus) .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); } diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_With_A_Default_Policy.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_With_A_Default_Policy.cs index 06a832047f..c36643c243 100644 --- a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_With_A_Default_Policy.cs +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Post/When_Posting_With_A_Default_Policy.cs @@ -87,6 +87,7 @@ public PostCommandTests() .ExternalBus(ExternalBusType.FireAndForget, externalBus) .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); } diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Mapper_Registry.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Mapper_Registry.cs new file mode 100644 index 0000000000..55908b050a --- /dev/null +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Mapper_Registry.cs @@ -0,0 +1,116 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2015 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using System.Collections.Generic; +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles; +using Paramore.Brighter.Core.Tests.TestHelpers; +using Paramore.Brighter.Observability; +using Polly; +using Polly.Registry; +using Xunit; + +namespace Paramore.Brighter.Core.Tests.CommandProcessors.Scheduler; + +[Collection("CommandProcessor")] +public class CommandSchedulingNoMessageMapperTests : IDisposable +{ + private readonly CommandProcessor _commandProcessor; + private readonly MyCommand _myCommand = new MyCommand(); + private Exception _exception; + + public CommandSchedulingNoMessageMapperTests() + { + var routingKey = new RoutingKey("MyCommand"); + _myCommand.Value = "Hello World"; + + var timeProvider = new FakeTimeProvider(); + InMemoryProducer producer = new(new InternalBus(), timeProvider) + { + Publication = {Topic = routingKey, RequestType = typeof(MyCommand)} + }; + + var messageMapperRegistry = new MessageMapperRegistry( + new SimpleMessageMapperFactory((_) => new MyCommandMessageMapper()), + null); + + var retryPolicy = Policy + .Handle() + .Retry(); + + var circuitBreakerPolicy = Policy + .Handle() + .CircuitBreaker(1, TimeSpan.FromMilliseconds(1)); + + var producerRegistry = new ProducerRegistry(new Dictionary + { + { routingKey, producer }, + }); + + var policyRegistry = new PolicyRegistry + { + { CommandProcessor.RETRYPOLICY, retryPolicy }, + { CommandProcessor.CIRCUITBREAKER, circuitBreakerPolicy } + }; + + var tracer = new BrighterTracer(timeProvider); + var outbox = new InMemoryOutbox(timeProvider) {Tracer = tracer}; + + IAmAnOutboxProducerMediator bus = new OutboxProducerMediator( + producerRegistry, + policyRegistry, + messageMapperRegistry, + new EmptyMessageTransformerFactory(), + new EmptyMessageTransformerFactoryAsync(), + tracer, + outbox + ); + + CommandProcessor.ClearServiceBus(); + _commandProcessor = new CommandProcessor( + new InMemoryRequestContextFactory(), + policyRegistry, + bus, + messageSchedulerFactory: new InMemoryMessageSchedulerFactory() + ); + } + + [Fact] + public void When_Scheduling_A_Message_And_There_Is_No_Message_Mapper_Factory() + { + _exception = Catch.Exception(() => _commandProcessor.SchedulerPost(_myCommand, TimeSpan.FromSeconds(10))); + _exception.Should().BeOfType(); + + _exception = Catch.Exception(() => _commandProcessor.SchedulerPost(_myCommand, DateTimeOffset.UtcNow.AddSeconds(10))); + _exception.Should().BeOfType(); + } + + public void Dispose() + { + CommandProcessor.ClearServiceBus(); + } +} \ No newline at end of file diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Mapper_Registry_Async.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Mapper_Registry_Async.cs new file mode 100644 index 0000000000..0c962ed0c8 --- /dev/null +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Mapper_Registry_Async.cs @@ -0,0 +1,108 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2015 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles; +using Paramore.Brighter.Core.Tests.TestHelpers; +using Paramore.Brighter.Observability; +using Polly; +using Polly.Registry; +using Xunit; + +namespace Paramore.Brighter.Core.Tests.CommandProcessors.Scheduler; + +[Collection("CommandProcessor")] +public class CommandSchedulingNoMessageMapperAsyncTests : IDisposable +{ + private readonly CommandProcessor _commandProcessor; + private readonly MyCommand _myCommand = new(); + private Exception _exception; + + public CommandSchedulingNoMessageMapperAsyncTests() + { + var routingKey = new RoutingKey("MyCommand"); + _myCommand.Value = "Hello World"; + + var timeProvider = new FakeTimeProvider(); + InMemoryProducer producer = new(new InternalBus(), timeProvider) + { + Publication = {Topic = routingKey, RequestType = typeof(MyCommand)} + }; + + var messageMapperRegistry = new MessageMapperRegistry( + new SimpleMessageMapperFactory((_) => new MyCommandMessageMapper()), + null); + + var retryPolicy = Policy + .Handle() + .RetryAsync(); + + var circuitBreakerPolicy = Policy + .Handle() + .CircuitBreakerAsync(1, TimeSpan.FromMilliseconds(1)); + + var policyRegistry = new PolicyRegistry { { CommandProcessor.RETRYPOLICYASYNC, retryPolicy }, { CommandProcessor.CIRCUITBREAKERASYNC, circuitBreakerPolicy } }; + var producerRegistry = new ProducerRegistry(new Dictionary {{routingKey, producer},}); + + var tracer = new BrighterTracer(timeProvider); + var outbox = new InMemoryOutbox(timeProvider) {Tracer = tracer}; + + IAmAnOutboxProducerMediator bus = new OutboxProducerMediator( + producerRegistry, + policyRegistry, + messageMapperRegistry, + new EmptyMessageTransformerFactory(), + new EmptyMessageTransformerFactoryAsync(), + tracer, + outbox + ); + + _commandProcessor = new CommandProcessor( + new InMemoryRequestContextFactory(), + policyRegistry, + bus, + messageSchedulerFactory: new InMemoryMessageSchedulerFactory() + ); + } + + [Fact] + public async Task When_Scheduling_A_Message_And_There_Is_No_Message_Mapper_Factory_Async() + { + _exception = await Catch.ExceptionAsync(async () => await _commandProcessor.SchedulerPostAsync(_myCommand, TimeSpan.FromMinutes(1))); + _exception.Should().BeOfType(); + + _exception = await Catch.ExceptionAsync(async () => await _commandProcessor.SchedulerPostAsync(_myCommand, DateTimeOffset.UtcNow.AddMinutes(1))); + _exception.Should().BeOfType(); + } + + public void Dispose() + { + CommandProcessor.ClearServiceBus(); + } +} \ No newline at end of file diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Scheduler_Factory.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Scheduler_Factory.cs new file mode 100644 index 0000000000..880879df25 --- /dev/null +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Scheduler_Factory.cs @@ -0,0 +1,115 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2015 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using System.Collections.Generic; +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles; +using Paramore.Brighter.Core.Tests.TestHelpers; +using Paramore.Brighter.Observability; +using Polly; +using Polly.Registry; +using Xunit; + +namespace Paramore.Brighter.Core.Tests.CommandProcessors.Scheduler; + +[Collection("CommandProcessor")] +public class CommandSchedulerNoMessageSchedulerFactoryTests : IDisposable +{ + private readonly CommandProcessor _commandProcessor; + private readonly MyCommand _myCommand = new MyCommand(); + private Exception _exception; + + public CommandSchedulerNoMessageSchedulerFactoryTests() + { + var routingKey = new RoutingKey("MyCommand"); + _myCommand.Value = "Hello World"; + + var timeProvider = new FakeTimeProvider(); + InMemoryProducer producer = new(new InternalBus(), timeProvider) + { + Publication = {Topic = routingKey, RequestType = typeof(MyCommand)} + }; + + var messageMapperRegistry = new MessageMapperRegistry( + new SimpleMessageMapperFactory((_) => new MyCommandMessageMapper()), + null); + + var retryPolicy = Policy + .Handle() + .Retry(); + + var circuitBreakerPolicy = Policy + .Handle() + .CircuitBreaker(1, TimeSpan.FromMilliseconds(1)); + + var producerRegistry = new ProducerRegistry(new Dictionary + { + { routingKey, producer }, + }); + + var policyRegistry = new PolicyRegistry + { + { CommandProcessor.RETRYPOLICY, retryPolicy }, + { CommandProcessor.CIRCUITBREAKER, circuitBreakerPolicy } + }; + + var tracer = new BrighterTracer(timeProvider); + var outbox = new InMemoryOutbox(timeProvider) {Tracer = tracer}; + + IAmAnOutboxProducerMediator bus = new OutboxProducerMediator( + producerRegistry, + policyRegistry, + messageMapperRegistry, + new EmptyMessageTransformerFactory(), + new EmptyMessageTransformerFactoryAsync(), + tracer, + outbox + ); + + CommandProcessor.ClearServiceBus(); + _commandProcessor = new CommandProcessor( + new InMemoryRequestContextFactory(), + policyRegistry, + bus + ); + } + + [Fact] + public void When_Scheduling_A_Message_And_There_Is_No_Message_Scheduler_Factory() + { + _exception = Catch.Exception(() => _commandProcessor.SchedulerPost(_myCommand, TimeSpan.FromSeconds(1))); + _exception.Should().BeOfType(); + + _exception = Catch.Exception(() => _commandProcessor.SchedulerPost(_myCommand, DateTimeOffset.UtcNow.AddSeconds(10))); + _exception.Should().BeOfType(); + } + + public void Dispose() + { + CommandProcessor.ClearServiceBus(); + } +} \ No newline at end of file diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Scheduler_Factory_Async.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Scheduler_Factory_Async.cs new file mode 100644 index 0000000000..019a5210da --- /dev/null +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_And_There_Is_No_Message_Scheduler_Factory_Async.cs @@ -0,0 +1,116 @@ +#region Licence +/* The MIT License (MIT) +Copyright © 2015 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using System.Collections.Generic; +using System.Threading.Tasks; +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles; +using Paramore.Brighter.Core.Tests.TestHelpers; +using Paramore.Brighter.Observability; +using Polly; +using Polly.Registry; +using Xunit; + +namespace Paramore.Brighter.Core.Tests.CommandProcessors.Scheduler; + +[Collection("CommandProcessor")] +public class CommandSchedulerNoMessageSchedulerFactoryAsyncTests : IDisposable +{ + private readonly CommandProcessor _commandProcessor; + private readonly MyCommand _myCommand = new MyCommand(); + private Exception _exception; + + public CommandSchedulerNoMessageSchedulerFactoryAsyncTests() + { + var routingKey = new RoutingKey("MyCommand"); + _myCommand.Value = "Hello World"; + + var timeProvider = new FakeTimeProvider(); + InMemoryProducer producer = new(new InternalBus(), timeProvider) + { + Publication = {Topic = routingKey, RequestType = typeof(MyCommand)} + }; + + var messageMapperRegistry = new MessageMapperRegistry( + new SimpleMessageMapperFactory((_) => new MyCommandMessageMapper()), + null); + + var retryPolicy = Policy + .Handle() + .Retry(); + + var circuitBreakerPolicy = Policy + .Handle() + .CircuitBreaker(1, TimeSpan.FromMilliseconds(1)); + + var producerRegistry = new ProducerRegistry(new Dictionary + { + { routingKey, producer }, + }); + + var policyRegistry = new PolicyRegistry + { + { CommandProcessor.RETRYPOLICY, retryPolicy }, + { CommandProcessor.CIRCUITBREAKER, circuitBreakerPolicy } + }; + + var tracer = new BrighterTracer(timeProvider); + var outbox = new InMemoryOutbox(timeProvider) {Tracer = tracer}; + + IAmAnOutboxProducerMediator bus = new OutboxProducerMediator( + producerRegistry, + policyRegistry, + messageMapperRegistry, + new EmptyMessageTransformerFactory(), + new EmptyMessageTransformerFactoryAsync(), + tracer, + outbox + ); + + CommandProcessor.ClearServiceBus(); + _commandProcessor = new CommandProcessor( + new InMemoryRequestContextFactory(), + policyRegistry, + bus + ); + } + + [Fact] + public async Task When_Scheduling_A_Message_And_There_Is_No_Message_Scheduler_Factory_Async() + { + _exception = await Catch.ExceptionAsync(async () => await _commandProcessor.SchedulerPostAsync(_myCommand, TimeSpan.FromSeconds(1))); + _exception.Should().BeOfType(); + + _exception = await Catch.ExceptionAsync(async () => await _commandProcessor.SchedulerPostAsync(_myCommand, DateTimeOffset.UtcNow.AddSeconds(10))); + _exception.Should().BeOfType(); + } + + public void Dispose() + { + CommandProcessor.ClearServiceBus(); + } +} \ No newline at end of file diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_To_The_Command_Processor.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_To_The_Command_Processor.cs new file mode 100644 index 0000000000..96dae8ebad --- /dev/null +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_To_The_Command_Processor.cs @@ -0,0 +1,147 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2015 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles; +using Paramore.Brighter.Observability; +using Polly; +using Polly.Registry; +using Xunit; + +namespace Paramore.Brighter.Core.Tests.CommandProcessors.Scheduler; + +[Collection("CommandProcessor")] +public class CommandProcessorSchedulerCommandTests : IDisposable +{ + private const string Topic = "MyCommand"; + private readonly CommandProcessor _commandProcessor; + private readonly MyCommand _myCommand; + private readonly Message _message; + private readonly InMemoryOutbox _outbox; + private readonly InternalBus _internalBus = new(); + private readonly FakeTimeProvider _timeProvider; + + public CommandProcessorSchedulerCommandTests() + { + _myCommand = new MyCommand { Value = $"Hello World {Guid.NewGuid():N}" }; + + var routingKey = new RoutingKey(Topic); + + _timeProvider = new FakeTimeProvider(); + _timeProvider.SetUtcNow(DateTimeOffset.UtcNow); + + InMemoryProducer producer = + new(_internalBus, _timeProvider) { Publication = { Topic = routingKey, RequestType = typeof(MyCommand) } }; + + _message = new Message( + new MessageHeader(_myCommand.Id, routingKey, MessageType.MT_COMMAND), + new MessageBody(JsonSerializer.Serialize(_myCommand, JsonSerialisationOptions.Options)) + ); + + var messageMapperRegistry = new MessageMapperRegistry( + new SimpleMessageMapperFactory((_) => new MyCommandMessageMapper()), + null); + messageMapperRegistry.Register(); + + var retryPolicy = Policy + .Handle() + .Retry(); + + var circuitBreakerPolicy = Policy + .Handle() + .CircuitBreaker(1, TimeSpan.FromMilliseconds(1)); + + var policyRegistry = new PolicyRegistry + { + { CommandProcessor.RETRYPOLICY, retryPolicy }, { CommandProcessor.CIRCUITBREAKER, circuitBreakerPolicy } + }; + var producerRegistry = + new ProducerRegistry(new Dictionary { { routingKey, producer }, }); + + var tracer = new BrighterTracer(_timeProvider); + _outbox = new InMemoryOutbox(_timeProvider) { Tracer = tracer }; + + IAmAnOutboxProducerMediator bus = new OutboxProducerMediator( + producerRegistry, + policyRegistry, + messageMapperRegistry, + new EmptyMessageTransformerFactory(), + new EmptyMessageTransformerFactoryAsync(), + tracer, + _outbox + ); + + CommandProcessor.ClearServiceBus(); + _commandProcessor = new CommandProcessor( + new InMemoryRequestContextFactory(), + policyRegistry, + bus, + messageSchedulerFactory: new InMemoryMessageSchedulerFactory(_timeProvider) + ); + } + + [Fact] + public void When_Scheduling_With_Delay_A_Message_To_The_Command_Processor() + { + _commandProcessor.SchedulerPost(_myCommand, TimeSpan.FromSeconds(10)); + _internalBus.Stream(new RoutingKey(Topic)).Any().Should().BeFalse(); + + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + + _internalBus.Stream(new RoutingKey(Topic)).Any().Should().BeTrue(); + + var message = _outbox.Get(_myCommand.Id, new RequestContext()); + message.Should().NotBeNull(); + message.Should().Be(_message); + } + + [Fact] + public void When_Scheduling_With_At_A_Message_To_The_Command_Processor() + { + _commandProcessor.SchedulerPost(_myCommand, _timeProvider.GetUtcNow().AddSeconds(10)); + _internalBus.Stream(new RoutingKey(Topic)).Any().Should().BeFalse(); + + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + + _internalBus.Stream(new RoutingKey(Topic)).Any().Should().BeTrue(); + + var message = _outbox.Get(_myCommand.Id, new RequestContext()); + message.Should().NotBeNull(); + + message.Should().Be(_message); + } + + public void Dispose() + { + CommandProcessor.ClearServiceBus(); + } +} diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_To_The_Command_Processor_Async.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_To_The_Command_Processor_Async.cs new file mode 100644 index 0000000000..81162eb68b --- /dev/null +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_A_Message_To_The_Command_Processor_Async.cs @@ -0,0 +1,139 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2015 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles; +using Paramore.Brighter.Observability; +using Polly; +using Polly.Registry; +using Xunit; + +namespace Paramore.Brighter.Core.Tests.CommandProcessors.Scheduler; + +[Collection("CommandProcessor")] +public class CommandProcessorSchedulerCommandAsyncTests : IDisposable +{ + private readonly CommandProcessor _commandProcessor; + private readonly MyCommand _myCommand; + private readonly InMemoryOutbox _outbox; + private readonly InternalBus _internalBus = new(); + private readonly RoutingKey _routingKey; + private readonly FakeTimeProvider _timeProvider; + + public CommandProcessorSchedulerCommandAsyncTests() + { + _myCommand = new() { Value = $"Hello World {Guid.NewGuid():N}" }; + _routingKey = new RoutingKey("MyCommand"); + _timeProvider = new FakeTimeProvider(); + _timeProvider.SetUtcNow(DateTimeOffset.UtcNow); + + InMemoryProducer producer = new(_internalBus, _timeProvider) + { + Publication = { Topic = _routingKey, RequestType = typeof(MyCommand) } + }; + + var messageMapperRegistry = new MessageMapperRegistry( + null, + new SimpleMessageMapperFactoryAsync((_) => new MyCommandMessageMapperAsync()) + ); + messageMapperRegistry.RegisterAsync(); + + var retryPolicy = Policy + .Handle() + .RetryAsync(); + + var circuitBreakerPolicy = Policy + .Handle() + .CircuitBreakerAsync(1, TimeSpan.FromMilliseconds(1)); + + var policyRegistry = new PolicyRegistry + { + { CommandProcessor.RETRYPOLICYASYNC, retryPolicy }, + { CommandProcessor.CIRCUITBREAKERASYNC, circuitBreakerPolicy } + }; + var producerRegistry = + new ProducerRegistry(new Dictionary { { _routingKey, producer }, }); + + var tracer = new BrighterTracer(_timeProvider); + _outbox = new InMemoryOutbox(_timeProvider) { Tracer = tracer }; + + IAmAnOutboxProducerMediator bus = new OutboxProducerMediator( + producerRegistry, + policyRegistry, + messageMapperRegistry, + new EmptyMessageTransformerFactory(), + new EmptyMessageTransformerFactoryAsync(), + tracer, + _outbox + ); + + CommandProcessor.ClearServiceBus(); + _commandProcessor = new CommandProcessor( + new InMemoryRequestContextFactory(), + policyRegistry, + bus, + messageSchedulerFactory: new InMemoryMessageSchedulerFactory(_timeProvider) + ); + } + + [Fact] + public async Task When_Scheduling_With_Delay_A_Message_To_The_Command_Processor_Async() + { + await _commandProcessor.SchedulerPostAsync(_myCommand, TimeSpan.FromSeconds(10)); + _internalBus.Stream(_routingKey).Any().Should().BeFalse(); + + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + + _internalBus.Stream(_routingKey).Any().Should().BeTrue(); + + _outbox + .Get(_myCommand.Id, new RequestContext()) + .Should().NotBeNull(); + } + + [Fact] + public async Task When_Scheduling_With_At_A_Message_To_The_Command_Processor_Async() + { + await _commandProcessor.SchedulerPostAsync(_myCommand, _timeProvider.GetUtcNow().AddSeconds(10)); + _internalBus.Stream(_routingKey).Any().Should().BeFalse(); + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + _internalBus.Stream(_routingKey).Any().Should().BeTrue(); + + _outbox + .Get(_myCommand.Id, new RequestContext()) + .Should().NotBeNull(); + } + + public void Dispose() + { + CommandProcessor.ClearServiceBus(); + } +} diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_With_A_Default_Policy.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_With_A_Default_Policy.cs new file mode 100644 index 0000000000..d1e2c75c0e --- /dev/null +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_With_A_Default_Policy.cs @@ -0,0 +1,142 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2015 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles; +using Paramore.Brighter.Observability; +using Xunit; + +namespace Paramore.Brighter.Core.Tests.CommandProcessors.Scheduler; + +[Collection("CommandProcessor")] +public class SchedulerCommandTests : IDisposable +{ + private readonly RoutingKey _routingKey = new("MyCommand"); + private readonly CommandProcessor _commandProcessor; + private readonly MyCommand _myCommand; + private readonly Message _message; + private readonly InMemoryOutbox _outbox; + private readonly InternalBus _internalBus = new(); + private readonly FakeTimeProvider _timeProvider; + + public SchedulerCommandTests() + { + _myCommand = new() { Value = $"Hello World {Guid.NewGuid():N}" }; + + _timeProvider = new FakeTimeProvider(); + _timeProvider.SetUtcNow(DateTimeOffset.UtcNow); + + var tracer = new BrighterTracer(_timeProvider); + _outbox = new InMemoryOutbox(_timeProvider) { Tracer = tracer }; + InMemoryProducer producer = new(_internalBus, _timeProvider) + { + Publication = { Topic = _routingKey, RequestType = typeof(MyCommand) } + }; + + _message = new Message( + new MessageHeader(_myCommand.Id, _routingKey, MessageType.MT_COMMAND), + new MessageBody(JsonSerializer.Serialize(_myCommand, JsonSerialisationOptions.Options)) + ); + + var messageMapperRegistry = + new MessageMapperRegistry( + new SimpleMessageMapperFactory((_) => new MyCommandMessageMapper()), + null); + messageMapperRegistry.Register(); + + var producerRegistry = + new ProducerRegistry(new Dictionary { { _routingKey, producer }, }); + + var externalBus = new OutboxProducerMediator( + producerRegistry: producerRegistry, + policyRegistry: new DefaultPolicy(), + mapperRegistry: messageMapperRegistry, + messageTransformerFactory: new EmptyMessageTransformerFactory(), + messageTransformerFactoryAsync: new EmptyMessageTransformerFactoryAsync(), + tracer: tracer, + outbox: _outbox + ); + + _commandProcessor = CommandProcessorBuilder.StartNew() + .Handlers(new HandlerConfiguration(new SubscriberRegistry(), new EmptyHandlerFactorySync())) + .DefaultPolicy() + .ExternalBus(ExternalBusType.FireAndForget, externalBus) + .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) + .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(new InMemoryMessageSchedulerFactory(_timeProvider)) + .Build(); + } + + [Fact] + public void When_Scheduling_With_A_Default_Policy_And_Passing_A_Delay() + { + _commandProcessor.SchedulerPost(_myCommand, TimeSpan.FromSeconds(10)); + _internalBus.Stream(new RoutingKey(_routingKey)).Any().Should().BeFalse(); + + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + + _internalBus.Stream(new RoutingKey(_routingKey)).Any().Should().BeTrue(); + + var message = _outbox.Get(_myCommand.Id, new RequestContext()); + message.Should().NotBeNull(); + message.Should().Be(_message); + } + + [Fact] + public void When_Scheduling_With_A_Default_Policy_And_Passing_An_At() + { + _commandProcessor.SchedulerPost(_myCommand, _timeProvider.GetUtcNow().AddSeconds(10)); + _internalBus.Stream(new RoutingKey(_routingKey)).Any().Should().BeFalse(); + + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + + _internalBus.Stream(new RoutingKey(_routingKey)).Any().Should().BeTrue(); + + var message = _outbox.Get(_myCommand.Id, new RequestContext()); + message.Should().NotBeNull(); + message.Should().Be(_message); + } + + public void Dispose() + { + CommandProcessor.ClearServiceBus(); + } + + internal class EmptyHandlerFactorySync : IAmAHandlerFactorySync + { + public IHandleRequests Create(Type handlerType, IAmALifetime lifetime) + { + return null; + } + + public void Release(IHandleRequests handler, IAmALifetime lifetime) { } + } +} diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_With_A_Default_Policy_Async.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_With_A_Default_Policy_Async.cs new file mode 100644 index 0000000000..78a4b4f7c9 --- /dev/null +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/Scheduler/When_Scheduling_With_A_Default_Policy_Async.cs @@ -0,0 +1,143 @@ +#region Licence + +/* The MIT License (MIT) +Copyright © 2015 Ian Cooper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the “Software”), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. */ + +#endregion + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles; +using Paramore.Brighter.Observability; +using Xunit; + +namespace Paramore.Brighter.Core.Tests.CommandProcessors.Scheduler; + +[Collection("CommandProcessor")] +public class SchedulerCommandAsyncTests : IDisposable +{ + private readonly RoutingKey _routingKey = new("MyCommand"); + private readonly CommandProcessor _commandProcessor; + private readonly MyCommand _myCommand; + private readonly Message _message; + private readonly InMemoryOutbox _outbox; + private readonly InternalBus _internalBus = new(); + private readonly FakeTimeProvider _timeProvider; + + public SchedulerCommandAsyncTests() + { + _myCommand = new() { Value = $"Hello World {Guid.NewGuid():N}" }; + + _timeProvider = new FakeTimeProvider(); + _timeProvider.SetUtcNow(DateTimeOffset.UtcNow); + + var tracer = new BrighterTracer(_timeProvider); + _outbox = new InMemoryOutbox(_timeProvider) { Tracer = tracer }; + InMemoryProducer producer = new(_internalBus, _timeProvider) + { + Publication = { Topic = _routingKey, RequestType = typeof(MyCommand) } + }; + + _message = new Message( + new MessageHeader(_myCommand.Id, _routingKey, MessageType.MT_COMMAND), + new MessageBody(JsonSerializer.Serialize(_myCommand, JsonSerialisationOptions.Options)) + ); + + var messageMapperRegistry = new MessageMapperRegistry( + null, + new SimpleMessageMapperFactoryAsync((_) => new MyCommandMessageMapperAsync()) + ); + messageMapperRegistry.RegisterAsync(); + + var producerRegistry = + new ProducerRegistry(new Dictionary { { _routingKey, producer }, }); + + var externalBus = new OutboxProducerMediator( + producerRegistry: producerRegistry, + policyRegistry: new DefaultPolicy(), + mapperRegistry: messageMapperRegistry, + messageTransformerFactory: new EmptyMessageTransformerFactory(), + messageTransformerFactoryAsync: new EmptyMessageTransformerFactoryAsync(), + tracer: tracer, + outbox: _outbox + ); + + _commandProcessor = CommandProcessorBuilder.StartNew() + .Handlers(new HandlerConfiguration(new SubscriberRegistry(), new EmptyHandlerFactorySync())) + .DefaultPolicy() + .ExternalBus(ExternalBusType.FireAndForget, externalBus) + .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) + .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(new InMemoryMessageSchedulerFactory(_timeProvider)) + .Build(); + } + + [Fact] + public async Task When_Scheduling_With_A_Default_Policy_And_Passing_A_Delay_Async() + { + await _commandProcessor.SchedulerPostAsync(_myCommand, TimeSpan.FromSeconds(10)); + _internalBus.Stream(new RoutingKey(_routingKey)).Any().Should().BeFalse(); + + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + + _internalBus.Stream(new RoutingKey(_routingKey)).Any().Should().BeTrue(); + + var message = _outbox.Get(_myCommand.Id, new RequestContext()); + message.Should().NotBeNull(); + message.Should().Be(_message); + } + + [Fact] + public async Task When_Scheduling_With_A_Default_Policy_And_Passing_An_At_Async() + { + await _commandProcessor.SchedulerPostAsync(_myCommand, _timeProvider.GetUtcNow().AddSeconds(10)); + _internalBus.Stream(new RoutingKey(_routingKey)).Any().Should().BeFalse(); + + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + + _internalBus.Stream(new RoutingKey(_routingKey)).Any().Should().BeTrue(); + + var message = _outbox.Get(_myCommand.Id, new RequestContext()); + message.Should().NotBeNull(); + message.Should().Be(_message); + } + + public void Dispose() + { + CommandProcessor.ClearServiceBus(); + } + + internal class EmptyHandlerFactorySync : IAmAHandlerFactorySync + { + public IHandleRequests Create(Type handlerType, IAmALifetime lifetime) + { + return null; + } + + public void Release(IHandleRequests handler, IAmALifetime lifetime) { } + } +} diff --git a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/TestDoubles/FakeErroringMessageProducerSync.cs b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/TestDoubles/FakeErroringMessageProducerSync.cs index 57a80661bc..f584359205 100644 --- a/tests/Paramore.Brighter.Core.Tests/CommandProcessors/TestDoubles/FakeErroringMessageProducerSync.cs +++ b/tests/Paramore.Brighter.Core.Tests/CommandProcessors/TestDoubles/FakeErroringMessageProducerSync.cs @@ -11,7 +11,8 @@ public class FakeErroringMessageProducerSync : IAmAMessageProducerSync public Publication Publication { get; } = new(); public Activity Span { get; set; } - + public IAmAMessageScheduler? Scheduler { get; set; } + public void Dispose() { } public void Send(Message message) diff --git a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Config_Off.cs b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Config_Off.cs index fd9b5bfe67..744c5eb98d 100644 --- a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Config_Off.cs +++ b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Config_Off.cs @@ -76,6 +76,7 @@ public CommandProcessorWithFeatureSwitchOffByConfigInPipelineTests() .NoExternalBus() .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); } diff --git a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Config_On.cs b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Config_On.cs index 9e2d64fcc6..193fe46ab8 100644 --- a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Config_On.cs +++ b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Config_On.cs @@ -76,6 +76,7 @@ public CommandProcessorWithFeatureSwitchOnByConfigInPipelineTests() .NoExternalBus() .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); } diff --git a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_Exception.cs b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_Exception.cs index fa32ac271f..b79997e2ad 100644 --- a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_Exception.cs +++ b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_Exception.cs @@ -74,6 +74,7 @@ public FeatureSwitchByConfigMissingConfigStrategyExceptionTests() .NoExternalBus() .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); } diff --git a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_SilentOff.cs b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_SilentOff.cs index 28b04b22c5..053c738b43 100644 --- a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_SilentOff.cs +++ b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_SilentOff.cs @@ -74,6 +74,7 @@ public FeatureSwitchByConfigMissingConfigStrategySilentOffTests() .NoExternalBus() .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); } diff --git a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_SilentOn.cs b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_SilentOn.cs index e74e91cd1d..e0bc0bc539 100644 --- a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_SilentOn.cs +++ b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Missing_Config_SilentOn.cs @@ -74,6 +74,7 @@ public FeatureSwitchByConfigMissingConfigStrategySilentOnTests() .NoExternalBus() .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); } diff --git a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Off.cs b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Off.cs index 7fcd6887bb..e1a4bcce24 100644 --- a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Off.cs +++ b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_Off.cs @@ -65,6 +65,7 @@ public CommandProcessorWithFeatureSwitchOffInPipelineTests() .NoExternalBus() .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); } diff --git a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_On.cs b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_On.cs index bb28d7db48..3435d262b2 100644 --- a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_On.cs +++ b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_A_Handler_Is_Feature_Switch_On.cs @@ -65,6 +65,7 @@ public CommandProcessorWithFeatureSwitchOnInPipelineTests() .NoExternalBus() .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); } diff --git a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_No_Feature_Switch_Config.cs b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_No_Feature_Switch_Config.cs index 9291e192e5..b715cb6ded 100644 --- a/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_No_Feature_Switch_Config.cs +++ b/tests/Paramore.Brighter.Core.Tests/FeatureSwitch/When_No_Feature_Switch_Config.cs @@ -67,6 +67,7 @@ public CommandProcessorWithNullFeatureSwitchConfig() .NoExternalBus() .ConfigureInstrumentation(new BrighterTracer(), InstrumentationOptions.All) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); } diff --git a/tests/Paramore.Brighter.Core.Tests/MessageDispatch/TestDoubles/SpyCommandProcessor.cs b/tests/Paramore.Brighter.Core.Tests/MessageDispatch/TestDoubles/SpyCommandProcessor.cs index 5b431c1899..d9e022f3cf 100644 --- a/tests/Paramore.Brighter.Core.Tests/MessageDispatch/TestDoubles/SpyCommandProcessor.cs +++ b/tests/Paramore.Brighter.Core.Tests/MessageDispatch/TestDoubles/SpyCommandProcessor.cs @@ -44,7 +44,9 @@ enum CommandType DepositAsync, Clear, ClearAsync, - Call + Call, + Scheduler, + SchedulerAsync } public class ClearParams @@ -57,6 +59,7 @@ public class ClearParams internal class SpyCommandProcessor : IAmACommandProcessor { private readonly Queue _requests = new Queue(); + private readonly Queue _scheduler = new Queue(); private readonly Dictionary _postBox = new(); public IList Commands { get; } = new List(); @@ -103,6 +106,38 @@ public virtual async Task PublishAsync( await completionSource.Task; } + public string SchedulerPost(TRequest request, TimeSpan delay, RequestContext? requestContext = null, + Dictionary? args = null) where TRequest : class, IRequest + { + _scheduler.Enqueue(request); + Commands.Add(CommandType.Scheduler); + return Guid.NewGuid().ToString(); + } + + public string SchedulerPost(TRequest request, DateTimeOffset at, RequestContext? requestContext = null, + Dictionary? args = null) where TRequest : class, IRequest + { + _scheduler.Enqueue(request); + Commands.Add(CommandType.Scheduler); + return Guid.NewGuid().ToString(); + } + + public Task SchedulerPostAsync(TRequest request, TimeSpan delay, RequestContext? requestContext = null, + Dictionary? args = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest + { + _scheduler.Enqueue(request); + Commands.Add(CommandType.SchedulerAsync); + return Task.FromResult(Guid.NewGuid().ToString()); + } + + public Task SchedulerPostAsync(TRequest request, DateTimeOffset at, RequestContext? requestContext = null, + Dictionary? args = null, bool continueOnCapturedContext = true, CancellationToken cancellationToken = default) where TRequest : class, IRequest + { + _scheduler.Enqueue(request); + Commands.Add(CommandType.SchedulerAsync); + return Task.FromResult(Guid.NewGuid().ToString()); + } + public virtual void Post(TRequest request, RequestContext requestContext = null, Dictionary args = null) where TRequest : class, IRequest { diff --git a/tests/Paramore.Brighter.Core.Tests/Observability/CommandProcessor/Scheduler/When_Scheduling_A_Request_A_Span_Is_Exported.cs b/tests/Paramore.Brighter.Core.Tests/Observability/CommandProcessor/Scheduler/When_Scheduling_A_Request_A_Span_Is_Exported.cs new file mode 100644 index 0000000000..e9585a488b --- /dev/null +++ b/tests/Paramore.Brighter.Core.Tests/Observability/CommandProcessor/Scheduler/When_Scheduling_A_Request_A_Span_Is_Exported.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Paramore.Brighter.Core.Tests.CommandProcessors.Post; +using Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles; +using Paramore.Brighter.Observability; +using Polly; +using Polly.Registry; +using Xunit; +using MyEvent = Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles.MyEvent; + +namespace Paramore.Brighter.Core.Tests.Observability.CommandProcessor.Scheduler; + +[Collection("Observability")] +public class CommandProcessorSchedulerObservabilityTests +{ + private readonly List _exportedActivities; + private readonly TracerProvider _traceProvider; + private readonly Brighter.CommandProcessor _commandProcessor; + private readonly FakeTimeProvider _timeProvider; + + public CommandProcessorSchedulerObservabilityTests() + { + var routingKey = new RoutingKey("MyEvent"); + + _timeProvider = new FakeTimeProvider(); + _timeProvider.SetUtcNow(DateTimeOffset.UtcNow); + + var builder = Sdk.CreateTracerProviderBuilder(); + _exportedActivities = new List(); + + _traceProvider = builder + .AddSource("Paramore.Brighter.Tests", "Paramore.Brighter") + .ConfigureResource(r => r.AddService("in-memory-tracer")) + .AddInMemoryExporter(_exportedActivities) + .Build(); + + Brighter.CommandProcessor.ClearServiceBus(); + + var registry = new SubscriberRegistry(); + + var handlerFactory = new PostCommandTests.EmptyHandlerFactorySync(); + + var retryPolicy = Policy + .Handle() + .Retry(); + + var policyRegistry = new PolicyRegistry { { Brighter.CommandProcessor.RETRYPOLICY, retryPolicy } }; + + var tracer = new BrighterTracer(_timeProvider); + InMemoryOutbox outbox = new(_timeProvider) { Tracer = tracer }; + + var messageMapperRegistry = new MessageMapperRegistry( + new SimpleMessageMapperFactory((_) => new MyEventMessageMapper()), + null); + messageMapperRegistry.Register(); + + var producerRegistry = new ProducerRegistry(new Dictionary + { + { + routingKey, + new InMemoryProducer(new InternalBus(), new FakeTimeProvider()) + { + Publication = { Topic = routingKey, RequestType = typeof(MyEvent) } + } + } + }); + + IAmAnOutboxProducerMediator bus = new OutboxProducerMediator( + producerRegistry, + policyRegistry, + messageMapperRegistry, + new EmptyMessageTransformerFactory(), + new EmptyMessageTransformerFactoryAsync(), + tracer, + outbox, + maxOutStandingMessages: -1 + ); + + _commandProcessor = new Brighter.CommandProcessor( + registry, + handlerFactory, + new InMemoryRequestContextFactory(), + policyRegistry, + bus, + tracer: tracer, + instrumentationOptions: InstrumentationOptions.All, + messageSchedulerFactory: new InMemoryMessageSchedulerFactory(_timeProvider) + ); + } + + [Fact] + public void When_Scheduling_A_Request_With_A_Delay_A_Span_Is_Exported() + { + //arrange + var parentActivity = new ActivitySource("Paramore.Brighter.Tests").StartActivity("BrighterTracerSpanTests"); + + var @event = new MyEvent(); + var context = new RequestContext { Span = parentActivity }; + + //act + _commandProcessor.SchedulerPost(@event, TimeSpan.FromSeconds(10), context); + parentActivity?.Stop(); + + _traceProvider.ForceFlush(); + + //assert + _exportedActivities.Count.Should().Be(2); + _exportedActivities.Any(a => a.Source.Name == "Paramore.Brighter").Should().BeTrue(); + var depositActivity = _exportedActivities.Single(a => + a.DisplayName == $"{nameof(MyEvent)} {CommandProcessorSpanOperation.Scheduler.ToSpanName()}"); + depositActivity.Should().NotBeNull(); + depositActivity.ParentId.Should().Be(parentActivity?.Id); + + depositActivity.Tags.Any(t => t.Key == BrighterSemanticConventions.RequestId && t.Value == @event.Id).Should() + .BeTrue(); + depositActivity.Tags.Any(t => t is { Key: BrighterSemanticConventions.RequestType, Value: nameof(MyEvent) }) + .Should().BeTrue(); + depositActivity.Tags + .Any(t => t.Key == BrighterSemanticConventions.RequestBody && + t.Value == JsonSerializer.Serialize(@event, JsonSerialisationOptions.Options)).Should().BeTrue(); + depositActivity.Tags.Any(t => t is { Key: BrighterSemanticConventions.Operation, Value: "scheduler" }).Should() + .BeTrue(); + + var events = depositActivity.Events.ToList(); + events.Count.Should().Be(1); + + //mapping a message should be an event + var mapperEvent = events.Single(e => e.Name == $"{nameof(MyEventMessageMapper)}"); + mapperEvent.Tags + .Any(a => a.Key == BrighterSemanticConventions.MapperName && + (string)a.Value == nameof(MyEventMessageMapper)).Should().BeTrue(); + mapperEvent.Tags.Any(a => a.Key == BrighterSemanticConventions.MapperType && (string)a.Value == "sync").Should() + .BeTrue(); + + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + } + + [Fact] + public void When_Scheduling_A_Request_With_A_DateTime_A_Span_Is_Exported() + { + //arrange + var parentActivity = new ActivitySource("Paramore.Brighter.Tests").StartActivity("BrighterTracerSpanTests"); + + var @event = new MyEvent(); + var context = new RequestContext { Span = parentActivity }; + + //act + _commandProcessor.SchedulerPost(@event, _timeProvider.GetUtcNow().AddSeconds(10), context); + parentActivity?.Stop(); + + _traceProvider.ForceFlush(); + + //assert + _exportedActivities.Count.Should().Be(2); + _exportedActivities.Any(a => a.Source.Name == "Paramore.Brighter").Should().BeTrue(); + var depositActivity = _exportedActivities.Single(a => + a.DisplayName == $"{nameof(MyEvent)} {CommandProcessorSpanOperation.Scheduler.ToSpanName()}"); + depositActivity.Should().NotBeNull(); + depositActivity.ParentId.Should().Be(parentActivity?.Id); + + depositActivity.Tags.Any(t => t.Key == BrighterSemanticConventions.RequestId && t.Value == @event.Id).Should() + .BeTrue(); + depositActivity.Tags.Any(t => t is { Key: BrighterSemanticConventions.RequestType, Value: nameof(MyEvent) }) + .Should().BeTrue(); + depositActivity.Tags + .Any(t => t.Key == BrighterSemanticConventions.RequestBody && + t.Value == JsonSerializer.Serialize(@event, JsonSerialisationOptions.Options)).Should().BeTrue(); + depositActivity.Tags.Any(t => t is { Key: BrighterSemanticConventions.Operation, Value: "scheduler" }).Should() + .BeTrue(); + + var events = depositActivity.Events.ToList(); + events.Count.Should().Be(1); + + //mapping a message should be an event + var mapperEvent = events.Single(e => e.Name == $"{nameof(MyEventMessageMapper)}"); + mapperEvent.Tags + .Any(a => a.Key == BrighterSemanticConventions.MapperName && + (string)a.Value == nameof(MyEventMessageMapper)).Should().BeTrue(); + mapperEvent.Tags.Any(a => a.Key == BrighterSemanticConventions.MapperType && (string)a.Value == "sync").Should() + .BeTrue(); + + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + } +} diff --git a/tests/Paramore.Brighter.Core.Tests/Observability/CommandProcessor/Scheduler/When_Scheduling_A_Request_A_Span_Is_Exported_Async.cs b/tests/Paramore.Brighter.Core.Tests/Observability/CommandProcessor/Scheduler/When_Scheduling_A_Request_A_Span_Is_Exported_Async.cs new file mode 100644 index 0000000000..5b910f844a --- /dev/null +++ b/tests/Paramore.Brighter.Core.Tests/Observability/CommandProcessor/Scheduler/When_Scheduling_A_Request_A_Span_Is_Exported_Async.cs @@ -0,0 +1,196 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using OpenTelemetry; +using OpenTelemetry.Resources; +using OpenTelemetry.Trace; +using Paramore.Brighter.Core.Tests.CommandProcessors.Post; +using Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles; +using Paramore.Brighter.Observability; +using Polly; +using Polly.Registry; +using Xunit; +using MyEvent = Paramore.Brighter.Core.Tests.CommandProcessors.TestDoubles.MyEvent; + +namespace Paramore.Brighter.Core.Tests.Observability.CommandProcessor.Scheduler; + +[Collection("Observability")] +public class CommandProcessorSchedulerAsyncObservabilityTests +{ + private readonly List _exportedActivities; + private readonly TracerProvider _traceProvider; + private readonly Brighter.CommandProcessor _commandProcessor; + private readonly FakeTimeProvider _timeProvider; + + public CommandProcessorSchedulerAsyncObservabilityTests() + { + var routingKey = new RoutingKey("MyEvent"); + + _timeProvider = new FakeTimeProvider(); + _timeProvider.SetUtcNow(DateTimeOffset.UtcNow); + + var builder = Sdk.CreateTracerProviderBuilder(); + _exportedActivities = new List(); + + _traceProvider = builder + .AddSource("Paramore.Brighter.Tests", "Paramore.Brighter") + .ConfigureResource(r => r.AddService("in-memory-tracer")) + .AddInMemoryExporter(_exportedActivities) + .Build(); + + Brighter.CommandProcessor.ClearServiceBus(); + + var registry = new SubscriberRegistry(); + + var handlerFactory = new PostCommandTests.EmptyHandlerFactorySync(); + + var retryPolicy = Policy + .Handle() + .RetryAsync(); + + var policyRegistry = new PolicyRegistry { { Brighter.CommandProcessor.RETRYPOLICYASYNC, retryPolicy } }; + + var tracer = new BrighterTracer(_timeProvider); + InMemoryOutbox outbox = new(_timeProvider) { Tracer = tracer }; + + + var messageMapperRegistry = new MessageMapperRegistry( + null, + new SimpleMessageMapperFactoryAsync((_) => new MyEventMessageMapperAsync()) + ); + messageMapperRegistry.RegisterAsync(); + + var producerRegistry = new ProducerRegistry(new Dictionary + { + { + routingKey, + new InMemoryProducer(new InternalBus(), new FakeTimeProvider()) + { + Publication = { Topic = routingKey, RequestType = typeof(MyEvent) } + } + } + }); + + IAmAnOutboxProducerMediator bus = new OutboxProducerMediator( + producerRegistry, + policyRegistry, + messageMapperRegistry, + new EmptyMessageTransformerFactory(), + new EmptyMessageTransformerFactoryAsync(), + tracer, + outbox, + maxOutStandingMessages: -1 + ); + + _commandProcessor = new Brighter.CommandProcessor( + registry, + handlerFactory, + new InMemoryRequestContextFactory(), + policyRegistry, + bus, + tracer: tracer, + instrumentationOptions: InstrumentationOptions.All, + messageSchedulerFactory: new InMemoryMessageSchedulerFactory(_timeProvider) + ); + } + + [Fact] + public async Task When_Scheduling_A_Request_With_A_Delay_A_Span_Is_Exported_Async() + { + //arrange + var parentActivity = new ActivitySource("Paramore.Brighter.Tests").StartActivity("BrighterTracerSpanTests"); + + var @event = new MyEvent(); + var context = new RequestContext { Span = parentActivity }; + + //act + await _commandProcessor.SchedulerPostAsync(@event, TimeSpan.FromSeconds(10), context); + parentActivity?.Stop(); + + _traceProvider.ForceFlush(); + + //assert + _exportedActivities.Count.Should().Be(2); + _exportedActivities.Any(a => a.Source.Name == "Paramore.Brighter").Should().BeTrue(); + var depositActivity = _exportedActivities.Single(a => + a.DisplayName == $"{nameof(MyEvent)} {CommandProcessorSpanOperation.Scheduler.ToSpanName()}"); + depositActivity.Should().NotBeNull(); + depositActivity.ParentId.Should().Be(parentActivity?.Id); + + depositActivity.Tags.Any(t => t.Key == BrighterSemanticConventions.RequestId && t.Value == @event.Id).Should() + .BeTrue(); + depositActivity.Tags.Any(t => t is { Key: BrighterSemanticConventions.RequestType, Value: nameof(MyEvent) }) + .Should().BeTrue(); + depositActivity.Tags + .Any(t => t.Key == BrighterSemanticConventions.RequestBody && + t.Value == JsonSerializer.Serialize(@event, JsonSerialisationOptions.Options)).Should().BeTrue(); + depositActivity.Tags.Any(t => t is { Key: BrighterSemanticConventions.Operation, Value: "scheduler" }).Should() + .BeTrue(); + + var events = depositActivity.Events.ToList(); + events.Count.Should().Be(1); + + //mapping a message should be an event + var mapperEvent = events.Single(e => e.Name == $"{nameof(MyEventMessageMapperAsync)}"); + mapperEvent.Tags + .Any(a => a.Key == BrighterSemanticConventions.MapperName && + (string)a.Value == nameof(MyEventMessageMapperAsync)).Should().BeTrue(); + mapperEvent.Tags.Any(a => a.Key == BrighterSemanticConventions.MapperType && (string)a.Value == "async").Should() + .BeTrue(); + + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + } + + [Fact] + public async Task When_Scheduling_A_Request_With_A_DateTime_A_Span_Is_Exported_Async() + { + //arrange + var parentActivity = new ActivitySource("Paramore.Brighter.Tests").StartActivity("BrighterTracerSpanTests"); + + var @event = new MyEvent(); + var context = new RequestContext { Span = parentActivity }; + + //act + await _commandProcessor.SchedulerPostAsync(@event, _timeProvider.GetUtcNow().AddSeconds(10), context); + parentActivity?.Stop(); + + _traceProvider.ForceFlush(); + + //assert + _exportedActivities.Count.Should().Be(2); + _exportedActivities.Any(a => a.Source.Name == "Paramore.Brighter").Should().BeTrue(); + var depositActivity = _exportedActivities.Single(a => + a.DisplayName == $"{nameof(MyEvent)} {CommandProcessorSpanOperation.Scheduler.ToSpanName()}"); + depositActivity.Should().NotBeNull(); + depositActivity.ParentId.Should().Be(parentActivity?.Id); + + depositActivity.Tags.Any(t => t.Key == BrighterSemanticConventions.RequestId && t.Value == @event.Id).Should() + .BeTrue(); + depositActivity.Tags.Any(t => t is { Key: BrighterSemanticConventions.RequestType, Value: nameof(MyEvent) }) + .Should().BeTrue(); + depositActivity.Tags + .Any(t => t.Key == BrighterSemanticConventions.RequestBody && + t.Value == JsonSerializer.Serialize(@event, JsonSerialisationOptions.Options)).Should().BeTrue(); + depositActivity.Tags.Any(t => t is { Key: BrighterSemanticConventions.Operation, Value: "scheduler" }).Should() + .BeTrue(); + + var events = depositActivity.Events.ToList(); + events.Count.Should().Be(1); + + //mapping a message should be an event + var mapperEvent = events.Single(e => e.Name == $"{nameof(MyEventMessageMapperAsync)}"); + mapperEvent.Tags + .Any(a => a.Key == BrighterSemanticConventions.MapperName && + (string)a.Value == nameof(MyEventMessageMapperAsync)).Should().BeTrue(); + mapperEvent.Tags.Any(a => a.Key == BrighterSemanticConventions.MapperType && (string)a.Value == "async").Should() + .BeTrue(); + + _timeProvider.Advance(TimeSpan.FromSeconds(10)); + } +} diff --git a/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher.cs b/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher.cs index 17a947532d..3876aaeb33 100644 --- a/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher.cs +++ b/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher.cs @@ -62,6 +62,7 @@ public DispatchBuilderTests() .NoExternalBus() .ConfigureInstrumentation(tracer, instrumentationOptions) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); _builder = DispatchBuilder.StartNew() diff --git a/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_async.cs b/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_async.cs index 9ab6f5682e..64ef0f85e6 100644 --- a/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_async.cs +++ b/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_async.cs @@ -62,6 +62,7 @@ public DispatchBuilderTestsAsync() .NoExternalBus() .ConfigureInstrumentation(tracer, instrumentationOptions) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); _builder = DispatchBuilder.StartNew() diff --git a/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_with_named_gateway.cs b/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_with_named_gateway.cs index 80c2398ca9..d087c883a1 100644 --- a/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_with_named_gateway.cs +++ b/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_with_named_gateway.cs @@ -57,6 +57,7 @@ public DispatchBuilderWithNamedGateway() .NoExternalBus() .ConfigureInstrumentation(tracer, instrumentationOptions) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); _builder = DispatchBuilder.StartNew() diff --git a/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_with_named_gateway_async.cs b/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_with_named_gateway_async.cs index 87125640be..36237eca29 100644 --- a/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_with_named_gateway_async.cs +++ b/tests/Paramore.Brighter.RMQ.Tests/MessageDispatch/When_building_a_dispatcher_with_named_gateway_async.cs @@ -58,6 +58,7 @@ public DispatchBuilderWithNamedGatewayAsync() .NoExternalBus() .ConfigureInstrumentation(tracer, instrumentationOptions) .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(null) .Build(); _builder = DispatchBuilder.StartNew() diff --git a/tests/ParamoreBrighter.Quartz.Tests/ParamoreBrighter.Quartz.Tests.csproj b/tests/ParamoreBrighter.Quartz.Tests/ParamoreBrighter.Quartz.Tests.csproj new file mode 100644 index 0000000000..3b42df7916 --- /dev/null +++ b/tests/ParamoreBrighter.Quartz.Tests/ParamoreBrighter.Quartz.Tests.csproj @@ -0,0 +1,37 @@ + + + + net8.0 + enable + enable + + false + true + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + + + + diff --git a/tests/ParamoreBrighter.Quartz.Tests/QuartzMessageSchedulerTest.cs b/tests/ParamoreBrighter.Quartz.Tests/QuartzMessageSchedulerTest.cs new file mode 100644 index 0000000000..61149a75c5 --- /dev/null +++ b/tests/ParamoreBrighter.Quartz.Tests/QuartzMessageSchedulerTest.cs @@ -0,0 +1,154 @@ +using System.Collections.Specialized; +using System.Text.Json; +using System.Transactions; +using FluentAssertions; +using Microsoft.Extensions.Time.Testing; +using Paramore.Brighter; +using Paramore.Brighter.Extensions; +using Paramore.Brighter.MessageScheduler.Quartz; +using Paramore.Brighter.Observability; +using Quartz; +using Quartz.Impl; +using Quartz.Simpl; +using Quartz.Spi; + +namespace ParamoreBrighter.Quartz.Tests; + +public class QuartzMessageSchedulerTest +{ + private readonly RoutingKey _routingKey = new("MyCommand"); + private readonly CommandProcessor _commandProcessor; + private readonly MyCommand _myCommand; + private readonly Message _message; + private readonly InMemoryOutbox _outbox; + private readonly InternalBus _internalBus = new(); + private readonly StdSchedulerFactory _schedulerFactory; + + public QuartzMessageSchedulerTest() + { + _myCommand = new() { Value = $"Hello World {Guid.NewGuid():N}" }; + + var timeProvider = new FakeTimeProvider(); + var tracer = new BrighterTracer(timeProvider); + _outbox = new InMemoryOutbox(timeProvider) { Tracer = tracer }; + InMemoryProducer producer = new(_internalBus, timeProvider) + { + Publication = { Topic = _routingKey, RequestType = typeof(MyCommand) } + }; + + _message = new Message( + new MessageHeader(_myCommand.Id, _routingKey, MessageType.MT_COMMAND), + new MessageBody(JsonSerializer.Serialize(_myCommand, JsonSerialisationOptions.Options)) + ); + + var messageMapperRegistry = + new MessageMapperRegistry( + new SimpleMessageMapperFactory((_) => new MyCommandMessageMapper()), + null); + messageMapperRegistry.Register(); + + var producerRegistry = + new ProducerRegistry(new Dictionary { { _routingKey, producer }, }); + + var externalBus = new OutboxProducerMediator( + producerRegistry: producerRegistry, + policyRegistry: new DefaultPolicy(), + mapperRegistry: messageMapperRegistry, + messageTransformerFactory: new EmptyMessageTransformerFactory(), + messageTransformerFactoryAsync: new EmptyMessageTransformerFactoryAsync(), + tracer: tracer, + outbox: _outbox + ); + + _schedulerFactory = SchedulerBuilder.Create(new NameValueCollection()) + .UseDefaultThreadPool(x => x.MaxConcurrency = 5) + .UseJobFactory() + .Build(); + + var scheduler = _schedulerFactory.GetScheduler().GetAwaiter().GetResult(); + scheduler.Start().GetAwaiter().GetResult(); + + _commandProcessor = CommandProcessorBuilder.StartNew() + .Handlers(new HandlerConfiguration(new SubscriberRegistry(), new EmptyHandlerFactorySync())) + .DefaultPolicy() + .ExternalBus(ExternalBusType.FireAndForget, externalBus) + .ConfigureInstrumentation(new BrighterTracer(TimeProvider.System), InstrumentationOptions.All) + .RequestContextFactory(new InMemoryRequestContextFactory()) + .MessageSchedulerFactory(new QuartzMessageSchedulerFactory(scheduler)) + .Build(); + + BrighterResolver.Processor = _commandProcessor; + } + + [Fact] + public void Quartz_Scheduling_A_Message() + { + _commandProcessor.SchedulerPost(_myCommand, TimeSpan.FromSeconds(1)); + + Task.Delay(TimeSpan.FromSeconds(2)).Wait(); + + _outbox.Get(_myCommand.Id, new RequestContext()).Should().NotBeNull(); + } + + [Fact] + public async Task Scheduling_A_Message_Async() + { + _commandProcessor.SchedulerPost(_myCommand, DateTimeOffset.UtcNow.AddSeconds(1)); + + await Task.Delay(TimeSpan.FromSeconds(10)); + + _outbox.Get(_myCommand.Id, new RequestContext()).Should().NotBeNull(); + } +} + +public class MyCommand : Command +{ + public MyCommand() + : base(Guid.NewGuid()) + + { + } + + public string Value { get; set; } + public bool WasCancelled { get; set; } + public bool TaskCompleted { get; set; } +} + +internal class EmptyHandlerFactorySync : IAmAHandlerFactorySync +{ + public IHandleRequests Create(Type handlerType, IAmALifetime lifetime) + { + return null; + } + + public void Release(IHandleRequests handler, IAmALifetime lifetime) { } +} + +internal class MyCommandMessageMapper : IAmAMessageMapper +{ + public IRequestContext Context { get; set; } + + public Message MapToMessage(MyCommand request, Publication publication) + { + var header = new MessageHeader(request.Id, publication.Topic, request.RequestToMessageType()); + var body = new MessageBody(JsonSerializer.Serialize(request, JsonSerialisationOptions.Options)); + var message = new Message(header, body); + return message; + } + + public MyCommand MapToRequest(Message message) + { + var command = JsonSerializer.Deserialize(message.Body.Value, JsonSerialisationOptions.Options); + return command; + } +} + +public class BrighterResolver : PropertySettingJobFactory +{ + public static IAmACommandProcessor Processor { get; set; } + + public override IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler) + { + return new QuartzBrighterJob(Processor); + } +}