├── .editorconfig ├── .gitattributes ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── PULL_REQUEST_TEMPLATE.md ├── README.md ├── appveyor.yml ├── build ├── build.cake ├── build.ps1 ├── build.sh └── cake.config ├── codecov.yml └── src ├── Chronicle.Integrations.MongoDB └── src │ ├── Chronicle.Integrations.MongoDB.csproj │ ├── ChronicleMongoSettings.cs │ ├── Extensions.cs │ └── Persistence │ ├── MongoSagaLog.cs │ ├── MongoSagaLogData.cs │ ├── MongoSagaState.cs │ └── MongoSagaStateRepository.cs ├── Chronicle.Integrations.Redis └── src │ ├── ChroncileRedisSettings.cs │ ├── Chronicle.Integrations.Redis.csproj │ ├── Extensions.cs │ └── Persistence │ ├── RedisSagaLog.cs │ ├── RedisSagaLogData.cs │ ├── RedisSagaState.cs │ └── RedisSagaStateRepository.cs ├── Chronicle.Tests ├── Builders │ ├── ChronicleBuilderTests.cs │ └── SagaContextBuilderTests.cs ├── Chronicle.Tests.csproj ├── Errors │ └── CheckTests.cs ├── Managers │ └── SagaSeekerTests.cs └── Persistence │ └── InMemorySagaLogTests.cs ├── Chronicle.sln ├── Chronicle ├── Async │ └── KeyedLocker.cs ├── Builders │ ├── ChronicleBuilder.cs │ └── SagaContextBuilder.cs ├── Chronicle.csproj ├── ChronicleException.cs ├── Errors │ └── Check.cs ├── Extensions.cs ├── IChronicleBuilder.cs ├── ISaga.cs ├── ISagaAction.cs ├── ISagaContext.cs ├── ISagaContextBuilder.cs ├── ISagaContextMetadata.cs ├── ISagaCoordinator.cs ├── ISagaLog.cs ├── ISagaLogData.cs ├── ISagaStartAction.cs ├── ISagaState.cs ├── ISagaStateRepository.cs ├── Managers │ ├── ISagaInitializer.cs │ ├── ISagaPostProcessor.cs │ ├── ISagaProcessor.cs │ ├── ISagaSeeker.cs │ ├── SagaCoordinator.cs │ ├── SagaInitializer.cs │ ├── SagaPostProcessor.cs │ ├── SagaProcessor.cs │ └── SagaSeeker.cs ├── Persistence │ ├── InMemorySagaLog.cs │ ├── InMemorySagaStateRepository.cs │ ├── SagaContextMetadata.cs │ ├── SagaLogData.cs │ └── SagaState.cs ├── Saga.cs ├── SagaContext.cs ├── SagaContextError.cs ├── SagaId.cs ├── SagaStates.cs ├── Testing │ └── InternalTesting.cs └── Utils │ ├── DateTimeExtensions.cs │ └── SagaExtensions.cs └── TestApp ├── Program.cs ├── SampleSaga.cs ├── Startup.cs └── TestApp.csproj /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = crlf 6 | indent_style = space 7 | 8 | [*.cs] 9 | indent_size = 4 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | dotnet_sort_system_directives_first = true 13 | csharp_new_line_before_open_brace = all 14 | csharp_new_line_before_else = true 15 | csharp_new_line_before_catch= true 16 | csharp_new_line_before_finally= true 17 | csharp_new_line_before_members_in_object_initializers= true 18 | csharp_new_line_before_members_in_anonymous_types= true 19 | csharp_new_line_between_query_expression_clauses= true 20 | csharp_indent_case_contents= true 21 | csharp_indent_switch_labels= true 22 | csharp_indent_labels = no_change 23 | csharp_space_after_cast = false 24 | csharp_space_after_keywords_in_control_flow_statements= true 25 | csharp_space_between_method_declaration_parameter_list_parentheses = false 26 | csharp_space_between_method_call_parameter_list_parentheses = false 27 | csharp_space_between_parentheses = false 28 | csharp_preserve_single_line_statements= true 29 | csharp_preserve_single_line_blocks= true 30 | 31 | [*.sln] 32 | indent_style = tab 33 | 34 | [*.{csproj, yml, config, cake}] 35 | indent_style = space 36 | indent_size = 2 37 | 38 | [*.md] 39 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | * text=auto 2 | 3 | *.cs text diff=csharp eol=crlf 4 | *.sln text merge=union eol=crlf 5 | *.csproj text merge=union eol=crlf -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | ## 4 | ## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore 5 | 6 | #file extensions 7 | *.dll 8 | *.pdb 9 | *.deps.json 10 | *.csproj.nuget.cache 11 | *.csproj.nuget.g.props 12 | *.csproj.nuget.g.targets 13 | *.assets.json 14 | 15 | #paths 16 | src/Valit/obj/ 17 | src/Valit/bin/ 18 | tests/Valit.Tests/TestResults 19 | 20 | # User-specific files 21 | *.suo 22 | *.user 23 | *.userosscache 24 | *.sln.docstates 25 | 26 | # User-specific files (MonoDevelop/Xamarin Studio) 27 | *.userprefs 28 | 29 | # Build results 30 | [Dd]ebug/ 31 | [Dd]ebugPublic/ 32 | [Rr]elease/ 33 | [Rr]eleases/ 34 | x64/ 35 | x86/ 36 | bld/ 37 | [Bb]in/ 38 | [Oo]bj/ 39 | [Ll]og/ 40 | 41 | # Visual Studio 2015 cache/options directory 42 | .vs/ 43 | # Uncomment if you have tasks that create the project's static files in wwwroot 44 | #wwwroot/ 45 | 46 | # MSTest test Results 47 | [Tt]est[Rr]esult*/ 48 | [Bb]uild[Ll]og.* 49 | 50 | # NUNIT 51 | *.VisualState.xml 52 | TestResult.xml 53 | 54 | # Build Results of an ATL Project 55 | [Dd]ebugPS/ 56 | [Rr]eleasePS/ 57 | dlldata.c 58 | 59 | # Benchmark Results 60 | BenchmarkDotNet.Artifacts/ 61 | 62 | # .NET Core 63 | project.lock.json 64 | project.fragment.lock.json 65 | artifacts/ 66 | **/Properties/launchSettings.json 67 | 68 | *_i.c 69 | *_p.c 70 | *_i.h 71 | *.ilk 72 | *.meta 73 | *.obj 74 | *.pch 75 | *.pdb 76 | *.pgc 77 | *.pgd 78 | *.rsp 79 | *.sbr 80 | *.tlb 81 | *.tli 82 | *.tlh 83 | *.tmp 84 | *.tmp_proj 85 | *.log 86 | *.vspscc 87 | *.vssscc 88 | .builds 89 | *.pidb 90 | *.svclog 91 | *.scc 92 | 93 | # Chutzpah Test files 94 | _Chutzpah* 95 | 96 | # Visual C++ cache files 97 | ipch/ 98 | *.aps 99 | *.ncb 100 | *.opendb 101 | *.opensdf 102 | *.sdf 103 | *.cachefile 104 | *.VC.db 105 | *.VC.VC.opendb 106 | 107 | # Visual Studio profiler 108 | *.psess 109 | *.vsp 110 | *.vspx 111 | *.sap 112 | 113 | # TFS 2012 Local Workspace 114 | $tf/ 115 | 116 | # Guidance Automation Toolkit 117 | *.gpState 118 | 119 | # ReSharper is a .NET coding add-in 120 | _ReSharper*/ 121 | *.[Rr]e[Ss]harper 122 | *.DotSettings.user 123 | 124 | # JustCode is a .NET coding add-in 125 | .JustCode 126 | 127 | # TeamCity is a build add-in 128 | _TeamCity* 129 | 130 | # DotCover is a Code Coverage Tool 131 | *.dotCover 132 | 133 | # AxoCover is a Code Coverage Tool 134 | .axoCover/* 135 | !.axoCover/settings.json 136 | 137 | # Visual Studio code coverage results 138 | *.coverage 139 | *.coveragexml 140 | 141 | # NCrunch 142 | _NCrunch_* 143 | .*crunch*.local.xml 144 | nCrunchTemp_* 145 | 146 | # MightyMoose 147 | *.mm.* 148 | AutoTest.Net/ 149 | 150 | # Web workbench (sass) 151 | .sass-cache/ 152 | 153 | # Installshield output folder 154 | [Ee]xpress/ 155 | 156 | # DocProject is a documentation generator add-in 157 | DocProject/buildhelp/ 158 | DocProject/Help/*.HxT 159 | DocProject/Help/*.HxC 160 | DocProject/Help/*.hhc 161 | DocProject/Help/*.hhk 162 | DocProject/Help/*.hhp 163 | DocProject/Help/Html2 164 | DocProject/Help/html 165 | 166 | # Click-Once directory 167 | publish/ 168 | 169 | # Publish Web Output 170 | *.[Pp]ublish.xml 171 | *.azurePubxml 172 | # Note: Comment the next line if you want to checkin your web deploy settings, 173 | # but database connection strings (with potential passwords) will be unencrypted 174 | *.pubxml 175 | *.publishproj 176 | 177 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 178 | # checkin your Azure Web App publish settings, but sensitive information contained 179 | # in these scripts will be unencrypted 180 | PublishScripts/ 181 | 182 | # NuGet Packages 183 | *.nupkg 184 | # The packages folder can be ignored because of Package Restore 185 | **/packages/* 186 | # except build/, which is used as an MSBuild target. 187 | !**/packages/build/ 188 | # Uncomment if necessary however generally it will be regenerated when needed 189 | #!**/packages/repositories.config 190 | # NuGet v3's project.json files produces more ignorable files 191 | *.nuget.props 192 | *.nuget.targets 193 | 194 | # Microsoft Azure Build Output 195 | csx/ 196 | *.build.csdef 197 | 198 | # Microsoft Azure Emulator 199 | ecf/ 200 | rcf/ 201 | 202 | # Windows Store app package directories and files 203 | AppPackages/ 204 | BundleArtifacts/ 205 | Package.StoreAssociation.xml 206 | _pkginfo.txt 207 | *.appx 208 | 209 | # Visual Studio cache files 210 | # files ending in .cache can be ignored 211 | *.[Cc]ache 212 | # but keep track of directories ending in .cache 213 | !*.[Cc]ache/ 214 | 215 | # Others 216 | ClientBin/ 217 | ~$* 218 | *~ 219 | *.dbmdl 220 | *.dbproj.schemaview 221 | *.jfm 222 | *.pfx 223 | *.publishsettings 224 | orleans.codegen.cs 225 | 226 | # Since there are multiple workflows, uncomment next line to ignore bower_components 227 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 228 | #bower_components/ 229 | 230 | # RIA/Silverlight projects 231 | Generated_Code/ 232 | 233 | # Backup & report files from converting an old project file 234 | # to a newer Visual Studio version. Backup files are not needed, 235 | # because we have git ;-) 236 | _UpgradeReport_Files/ 237 | Backup*/ 238 | UpgradeLog*.XML 239 | UpgradeLog*.htm 240 | 241 | # SQL Server files 242 | *.mdf 243 | *.ldf 244 | *.ndf 245 | 246 | # Business Intelligence projects 247 | *.rdl.data 248 | *.bim.layout 249 | *.bim_*.settings 250 | 251 | # Microsoft Fakes 252 | FakesAssemblies/ 253 | 254 | # GhostDoc plugin setting file 255 | *.GhostDoc.xml 256 | 257 | # Node.js Tools for Visual Studio 258 | .ntvs_analysis.dat 259 | node_modules/ 260 | 261 | # Typescript v1 declaration files 262 | typings/ 263 | 264 | # Visual Studio 6 build log 265 | *.plg 266 | 267 | # Visual Studio 6 workspace options file 268 | *.opt 269 | 270 | # Visual Studio 6 auto-generated workspace file (contains which files were open etc.) 271 | *.vbw 272 | 273 | # Visual Studio LightSwitch build output 274 | **/*.HTMLClient/GeneratedArtifacts 275 | **/*.DesktopClient/GeneratedArtifacts 276 | **/*.DesktopClient/ModelManifest.xml 277 | **/*.Server/GeneratedArtifacts 278 | **/*.Server/ModelManifest.xml 279 | _Pvt_Extensions 280 | 281 | # Paket dependency manager 282 | .paket/paket.exe 283 | paket-files/ 284 | 285 | # FAKE - F# Make 286 | .fake/ 287 | 288 | # JetBrains Rider 289 | .idea/ 290 | *.sln.iml 291 | 292 | # CodeRush 293 | .cr/ 294 | 295 | # Python Tools for Visual Studio (PTVS) 296 | __pycache__/ 297 | *.pyc 298 | 299 | # Cake - Uncomment if you are using it 300 | tools/** 301 | !tools/packages.config 302 | 303 | # Tabs Studio 304 | *.tss 305 | 306 | # Telerik's JustMock configuration file 307 | *.jmconfig 308 | 309 | # BizTalk build output 310 | *.btp.cs 311 | *.btm.cs 312 | *.odx.cs 313 | *.xsd.cs 314 | 315 | # diff 316 | *.orig -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Chronicle 2 | 3 | Do you have great ideas which will make Chronicle even better? That's awesome! Feel free to create **issues or pull request** here on GitHub! 4 | 5 | The purpose of this text is to help you start working with the source code without any trouble. Simply, follow the steps below and... have fun :) 6 | 7 | ## Clone the repository 8 | In order to start working with Chronicle, you need to clone the repository from GitHub using the following command: 9 | ``` 10 | git clone https://github.com/chronicle-stack/Chronicle.git 11 | ``` 12 | 13 | ## Create new branch 14 | After you're done make sure you are one the **develop** branch: 15 | ``` 16 | git checkout develop 17 | ``` 18 | 19 | Then you can create your own branch for new feature, bug fix or whatever you want. We use very simple naming convention for naming branches: 20 | ``` 21 | feature/ 22 | ``` 23 | 24 | However, if there's no issue related to your feature you can put the short description instead using **snake case** like in the example below: 25 | ``` 26 | feature/my_new_awesome_validation_rule 27 | ``` 28 | 29 | Having a proper name for your branch, create it directly from the develop: 30 | ``` 31 | git checkout -b 32 | ``` 33 | 34 | ## Creating unit tests 35 | We do our best to make Chronicle a reliable library. That's why we pay attention to unit tests for each new functionality. You can find them inside ```tests``` folder. Select a subfolder which should contain new unit tests or create new if none of them suits new functionality. The name of each unit test should follow the convention: 36 | ``` 37 | Method_Result_When_Condition 38 | ``` 39 | 40 | Here's an example: 41 | ``` 42 | Float_IsPositive_Fails_When_Given_Value_Is_Null 43 | ``` 44 | 45 | Try to avaoid multiple ```Assert``` inside single unit tests. 46 | 47 | When you're done make sure all tests passess. Navigate to the ```Chronicle.Tests``` project and run the following command: 48 | ``` 49 | dotnet test 50 | ``` 51 | 52 | You can also run the following command for continuous testing: 53 | ``` 54 | dotnet watch test // this will compile the project and rerun the tests on every file change 55 | ``` 56 | 57 | Alternatively, you can use our **Cake script** which is placed in the root folder. Navigate there and run: 58 | ``` 59 | ./build.sh //on Unix 60 | ``` 61 | ``` 62 | ./build.ps1 //on Windows 63 | ``` 64 | 65 | ## Creating a pull request 66 | When the code is stable, you can submit your changes by creating a pull request. First, push your branch to origin: 67 | ``` 68 | git push origin 69 | ``` 70 | 71 | Then go to the **GitHub -> Pull Request -> New Pull Request**. 72 | Select **develop** as base and your branch as compare. We provide default template for PR description: 73 | 74 | ![PR_Template](http://foreverframe.net/wp-content/uploads/2017/09/Screen-Shot-2017-09-27-at-21.16.02.png) 75 | 76 | Make sure: 77 | - PR title is short and concludes work you've done 78 | - GitHub issue number and link is inluded in the description 79 | - You described changes to the codebase 80 | 81 | When it's done, simply create your pull request. We use [AppVeyor](https://ci.appveyor.com/project/GooRiOn/chronicle/branch/master) as CI system and [Codecov](https://codecov.io/gh/chronicle-stack/chronicle/branch/master) as code coverage analyzer. After you push your changes these two tools will take a look at your code. First, the AppVeyor will check whether project builds and all unit tests passess. Then Codecov bot will post a short report which will present code coverage after your changes: 82 | 83 | ![CC_Report](http://foreverframe.net/wp-content/uploads/2017/10/Screen-Shot-2017-10-22-at-13.13.15.png) 84 | 85 | Each PR must fulfill certain conditions before it can be merged: 86 | 87 | - The build must succeed on AppVeyor 88 | - Code coverage can't be discreassed by the PR 89 | - One of the owners must approve your changes 90 | 91 | 92 | If some of the above won't be fulfilled (due to change request or some mistake) simply fix it locally on your machine, create new commit and push it to origin. This will update the exisitng PR and will kick off all checks again. 93 | 94 | If everything will be fine, your changes will be merged into develop, branch will be deleted and related issue will be closed. 95 | 96 | # WELL DONE AND THANK YOU VERY MUCH! 97 | 98 | 99 | 100 | 101 | 102 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Dariusz Pawlukiewicz 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | # Info 2 | This Pull Request is related to issue no. [] ([]) 3 | 4 | # Changes 5 | - ... -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### Chronicle 2 | ![42150754](https://user-images.githubusercontent.com/7096476/64911747-ef4be100-d725-11e9-98f3-43331714afa7.png) 3 | 4 | 5 | 6 | Chronicle is simple **process manager/saga pattern** implementation for .NET Core that helps you manage long-living and distirbuted transactions. 7 | 8 | | | master | develop | 9 | |---|--------|----------| 10 | |AppVeyor|[![Build status](https://ci.appveyor.com/api/projects/status/rma8prlvhjtql7ct/branch/master?svg=true)](https://ci.appveyor.com/project/GooRiOn/chronicle/branch/master)|[![Build status](https://ci.appveyor.com/api/projects/status/rma8prlvhjtql7ct/branch/develop?svg=true)](https://ci.appveyor.com/project/GooRiOn/chronicle/branch/develop)| 11 | |CodeCov|[![codecov](https://codecov.io/gh/chronicle-stack/Chronicle/branch/master/graph/badge.svg)](https://codecov.io/gh/chronicle-stack/Chronicle)|[![codecov](https://codecov.io/gh/chronicle-stack/Chronicle/branch/develop/graph/badge.svg)](https://codecov.io/gh/chronicle-stack/Chronicle)| 12 | 13 | # Installation 14 | Chornicle is available on [NuGet](https://www.nuget.org/packages/Chronicle_/) 15 | ### Package manager 16 | ```bash 17 | Install-Package Chronicle_ -Version 3.2.1 18 | ``` 19 | 20 | ### .NET CLI 21 | ```bash 22 | dotnet add package Chronicle_ --version 3.2.1 23 | ``` 24 | 25 | # Getting started 26 | In order to create and process a saga you need to go through a few steps: 27 | 1. Create a class that dervies from either ``Saga`` or ``Saga``. 28 | 2. Inside your saga implemention, inherit from one or several ``ISagaStartAction`` and ``ISagaAction`` to implement ``HandleAsync()`` and ``CompensateAsync()`` methods for each message type. An initial step must be implemented as an ``ISagaStartAction``, while the rest can be ``ISagaAction``. It's worth mentioning that you can implement as many ``ISagaStartAction`` as you want. In this case, the first incoming message is going to initialize the saga and any subsequent ``ISagaStartAction`` or ``ISagaAction`` will only update the current saga state. 29 | 3. Register all your sagas in ``Startup.cs`` by calling ``services.AddChronicle()``. By default, ``AddChronicle()`` will use the ``InMemorySagaStateRepository`` and ``InMemorySagaLog`` for maintaining ``SagaState`` and for logging ``SagaLogData`` in the ``SagaLog``. The ``SagaLog`` maintains a historical record of which message handlers have been executed. Optionally, ``AddChronicle()`` accepts an ``Action`` parameter which provides access to ``UseSagaStateRepository()`` and ``UseSagaLog()`` for custom implementations of ``ISagaStateRepository`` and ``ISagaLog``. **If either method is called, then both methods need to be called**. 30 | 4. Inject ``ISagaCoordinator`` and invoke ``ProcessAsync()`` methods passing a message. The coordinator will take care of everything by looking for all implemented sagas that can handle a given message. 31 | 5. To complete a successful saga, call ``CompleteSaga()`` or ``CompleteSagaAsync()``. This will update the ``SagaState`` to Completed. To flag a saga which has failed or been rejected, call the ``Reject()`` or ``RejectAsync()`` methods to update the ``SagaState`` to Rejected. Doing so will utilize the ``SagaLog`` to call each message type's ``CompensateAsync()`` in the reverse order of their respective ``HandleAsync()`` method was called. Additionally, an unhanded exception thrown from a ``HandleAsync()`` method will cause ``Reject()`` to be called and begin the compensation. 32 | 33 | Below is the very simple example of saga that completes once both messages (``Message1`` and ``Message2``) are received: 34 | 35 | ```csharp 36 | public class Message1 37 | { 38 | public string Text { get; set; } 39 | } 40 | 41 | public class Message2 42 | { 43 | public string Text { get; set; } 44 | } 45 | 46 | public class SagaData 47 | { 48 | public bool IsMessage1Received { get; set; } 49 | public bool IsMessage2Received { get; set; } 50 | } 51 | 52 | public class SampleSaga : Saga, ISagaStartAction, ISagaAction 53 | { 54 | public Task HandleAsync(Message1 message, ISagaContext context) 55 | { 56 | Data.IsMessage1Received = true; 57 | Console.WriteLine($"Received message1 with message: {message.Text}"); 58 | CompleteSaga(); 59 | return Task.CompletedTask; 60 | } 61 | 62 | public Task HandleAsync(Message2 message, ISagaContext context) 63 | { 64 | Data.IsMessage2Received = true; 65 | Console.WriteLine($"Received message2 with message: {message.Text}"); 66 | CompleteSaga(); 67 | return Task.CompletedTask; 68 | } 69 | 70 | public Task CompensateAsync(Message1 message, ISagaContext context) 71 | => Task.CompletedTask; 72 | 73 | public Task CompensateAsync(Message2 message, ISagaContext context) 74 | => Task.CompletedTask; 75 | 76 | private void CompleteSaga() 77 | { 78 | if(Data.IsMessage1Received && Data.IsMessage2Received) 79 | { 80 | Complete(); 81 | Console.WriteLine("SAGA COMPLETED"); 82 | } 83 | } 84 | } 85 | 86 | ``` 87 | 88 | Both messages are processed by mentioned coordinator: 89 | 90 | ```csharp 91 | var coordinator = app.ApplicationServices.GetService(); 92 | 93 | var context = SagaContext 94 | .Create() 95 | .WithCorrelationId(Guid.NewGuid()) 96 | .Build(); 97 | 98 | coordinator.ProcessAsync(new Message1 { Text = "Hello" }, context); 99 | coordinator.ProcessAsync(new Message2 { Text = "World" }, context); 100 | ``` 101 | 102 | The result looks as follows: 103 | 104 | ![Result](https://user-images.githubusercontent.com/7096476/53180548-0c885900-35f6-11e9-864b-6b6d13641f2a.png) 105 | 106 | # Documentation 107 | If you're looking for documentation, you can find it [here](https://chronicle.readthedocs.io/en/latest/). 108 | 109 | # Icon 110 | Icon made by Smashicons from [www.flaticon.com](http://flaticon.com) is licensed by [Creative Commons BY 3.0](http://creativecommons.org/licenses/by/3.0/) 111 | -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | init: 2 | - git config --global core.autocrlf true 3 | 4 | image: Visual Studio 2015 5 | 6 | before_build: 7 | - choco install opencover.portable 8 | - choco install codecov 9 | 10 | build_script: 11 | - cd build 12 | - ps: .\build.ps1 13 | - OpenCover.Console.exe -register:user -target:"C:/Program Files/dotnet/dotnet.exe" -targetargs:"test --logger:trx;LogFileName=results.trx /p:DebugType=full C:\projects\chronicle\src\Chronicle.Tests\Chronicle.Tests.csproj" -filter:"+[Chronicle*]* -[Chronicle.Tests*]*" -output:".\chronicle_coverage.xml" -oldstyle 14 | - codecov -f .\chronicle_coverage.xml -t $(codecov_token) 15 | 16 | test: off 17 | 18 | branches: 19 | only: 20 | - develop 21 | - master 22 | 23 | cache: 24 | - tools -> build.cake 25 | - packages -> build.cake 26 | -------------------------------------------------------------------------------- /build/build.cake: -------------------------------------------------------------------------------- 1 | #tool "nuget:?package=xunit.runner.console" 2 | 3 | var target = Argument("target", "Default"); 4 | var configuration = Argument("configuration", "Release"); 5 | 6 | Task("dotnet-restore") 7 | .Does(() => 8 | { 9 | DotNetCoreRestore("../src/Chronicle.sln"); 10 | }); 11 | 12 | Task("dotnet-build") 13 | .IsDependentOn("dotnet-restore") 14 | .Does(() => 15 | { 16 | DotNetCoreBuild("../src/Chronicle.sln", new DotNetCoreBuildSettings 17 | { 18 | Configuration = configuration, 19 | MSBuildSettings = new DotNetCoreMSBuildSettings 20 | { 21 | TreatAllWarningsAs = MSBuildTreatAllWarningsAs.Error 22 | } 23 | }); 24 | }); 25 | 26 | Task("run-xunit-tests") 27 | .IsDependentOn("dotnet-build") 28 | .Does(() => 29 | { 30 | var settings = new DotNetCoreTestSettings 31 | { 32 | Configuration = configuration 33 | }; 34 | 35 | DotNetCoreTest("../src/Chronicle.Tests/Chronicle.Tests.csproj", settings); 36 | }); 37 | 38 | Task("Default") 39 | .IsDependentOn("run-xunit-tests"); 40 | 41 | RunTarget(target); -------------------------------------------------------------------------------- /build/build.ps1: -------------------------------------------------------------------------------- 1 | ########################################################################## 2 | # This is the Cake bootstrapper script for PowerShell. 3 | # This file was downloaded from https://github.com/cake-build/resources 4 | # Feel free to change this file to fit your needs. 5 | ########################################################################## 6 | 7 | <# 8 | 9 | .SYNOPSIS 10 | This is a Powershell script to bootstrap a Cake build. 11 | 12 | .DESCRIPTION 13 | This Powershell script will download NuGet if missing, restore NuGet tools (including Cake) 14 | and execute your Cake build script with the parameters you provide. 15 | 16 | .PARAMETER Script 17 | The build script to execute. 18 | .PARAMETER Target 19 | The build script target to run. 20 | .PARAMETER Configuration 21 | The build configuration to use. 22 | .PARAMETER Verbosity 23 | Specifies the amount of information to be displayed. 24 | .PARAMETER ShowDescription 25 | Shows description about tasks. 26 | .PARAMETER DryRun 27 | Performs a dry run. 28 | .PARAMETER Experimental 29 | Uses the nightly builds of the Roslyn script engine. 30 | .PARAMETER Mono 31 | Uses the Mono Compiler rather than the Roslyn script engine. 32 | .PARAMETER SkipToolPackageRestore 33 | Skips restoring of packages. 34 | .PARAMETER ScriptArgs 35 | Remaining arguments are added here. 36 | 37 | .LINK 38 | https://cakebuild.net 39 | 40 | #> 41 | 42 | [CmdletBinding()] 43 | Param( 44 | [string]$Script = "build.cake", 45 | [string]$Target, 46 | [string]$Configuration, 47 | [ValidateSet("Quiet", "Minimal", "Normal", "Verbose", "Diagnostic")] 48 | [string]$Verbosity, 49 | [switch]$ShowDescription, 50 | [Alias("WhatIf", "Noop")] 51 | [switch]$DryRun, 52 | [switch]$Experimental, 53 | [switch]$Mono, 54 | [switch]$SkipToolPackageRestore, 55 | [Parameter(Position=0,Mandatory=$false,ValueFromRemainingArguments=$true)] 56 | [string[]]$ScriptArgs 57 | ) 58 | 59 | [Reflection.Assembly]::LoadWithPartialName("System.Security") | Out-Null 60 | function MD5HashFile([string] $filePath) 61 | { 62 | if ([string]::IsNullOrEmpty($filePath) -or !(Test-Path $filePath -PathType Leaf)) 63 | { 64 | return $null 65 | } 66 | 67 | [System.IO.Stream] $file = $null; 68 | [System.Security.Cryptography.MD5] $md5 = $null; 69 | try 70 | { 71 | $md5 = [System.Security.Cryptography.MD5]::Create() 72 | $file = [System.IO.File]::OpenRead($filePath) 73 | return [System.BitConverter]::ToString($md5.ComputeHash($file)) 74 | } 75 | finally 76 | { 77 | if ($file -ne $null) 78 | { 79 | $file.Dispose() 80 | } 81 | } 82 | } 83 | 84 | function GetProxyEnabledWebClient 85 | { 86 | $wc = New-Object System.Net.WebClient 87 | $proxy = [System.Net.WebRequest]::GetSystemWebProxy() 88 | $proxy.Credentials = [System.Net.CredentialCache]::DefaultCredentials 89 | $wc.Proxy = $proxy 90 | return $wc 91 | } 92 | 93 | Write-Host "Preparing to run build script..." 94 | 95 | if(!$PSScriptRoot){ 96 | $PSScriptRoot = Split-Path $MyInvocation.MyCommand.Path -Parent 97 | } 98 | 99 | $TOOLS_DIR = Join-Path $PSScriptRoot "tools" 100 | $ADDINS_DIR = Join-Path $TOOLS_DIR "Addins" 101 | $MODULES_DIR = Join-Path $TOOLS_DIR "Modules" 102 | $NUGET_EXE = Join-Path $TOOLS_DIR "nuget.exe" 103 | $CAKE_EXE = Join-Path $TOOLS_DIR "Cake/Cake.exe" 104 | $NUGET_URL = "https://dist.nuget.org/win-x86-commandline/latest/nuget.exe" 105 | $PACKAGES_CONFIG = Join-Path $TOOLS_DIR "packages.config" 106 | $PACKAGES_CONFIG_MD5 = Join-Path $TOOLS_DIR "packages.config.md5sum" 107 | $ADDINS_PACKAGES_CONFIG = Join-Path $ADDINS_DIR "packages.config" 108 | $MODULES_PACKAGES_CONFIG = Join-Path $MODULES_DIR "packages.config" 109 | 110 | # Make sure tools folder exists 111 | if ((Test-Path $PSScriptRoot) -and !(Test-Path $TOOLS_DIR)) { 112 | Write-Verbose -Message "Creating tools directory..." 113 | New-Item -Path $TOOLS_DIR -Type directory | out-null 114 | } 115 | 116 | # Make sure that packages.config exist. 117 | if (!(Test-Path $PACKAGES_CONFIG)) { 118 | Write-Verbose -Message "Downloading packages.config..." 119 | try { 120 | $wc = GetProxyEnabledWebClient 121 | $wc.DownloadFile("https://cakebuild.net/download/bootstrapper/packages", $PACKAGES_CONFIG) 122 | } catch { 123 | Throw "Could not download packages.config." 124 | } 125 | } 126 | 127 | # Try find NuGet.exe in path if not exists 128 | if (!(Test-Path $NUGET_EXE)) { 129 | Write-Verbose -Message "Trying to find nuget.exe in PATH..." 130 | $existingPaths = $Env:Path -Split ';' | Where-Object { (![string]::IsNullOrEmpty($_)) -and (Test-Path $_ -PathType Container) } 131 | $NUGET_EXE_IN_PATH = Get-ChildItem -Path $existingPaths -Filter "nuget.exe" | Select -First 1 132 | if ($NUGET_EXE_IN_PATH -ne $null -and (Test-Path $NUGET_EXE_IN_PATH.FullName)) { 133 | Write-Verbose -Message "Found in PATH at $($NUGET_EXE_IN_PATH.FullName)." 134 | $NUGET_EXE = $NUGET_EXE_IN_PATH.FullName 135 | } 136 | } 137 | 138 | # Try download NuGet.exe if not exists 139 | if (!(Test-Path $NUGET_EXE)) { 140 | Write-Verbose -Message "Downloading NuGet.exe..." 141 | try { 142 | $wc = GetProxyEnabledWebClient 143 | $wc.DownloadFile($NUGET_URL, $NUGET_EXE) 144 | } catch { 145 | Throw "Could not download NuGet.exe." 146 | } 147 | } 148 | 149 | # Save nuget.exe path to environment to be available to child processed 150 | $ENV:NUGET_EXE = $NUGET_EXE 151 | 152 | # Restore tools from NuGet? 153 | if(-Not $SkipToolPackageRestore.IsPresent) { 154 | Push-Location 155 | Set-Location $TOOLS_DIR 156 | 157 | # Check for changes in packages.config and remove installed tools if true. 158 | [string] $md5Hash = MD5HashFile($PACKAGES_CONFIG) 159 | if((!(Test-Path $PACKAGES_CONFIG_MD5)) -Or 160 | ($md5Hash -ne (Get-Content $PACKAGES_CONFIG_MD5 ))) { 161 | Write-Verbose -Message "Missing or changed package.config hash..." 162 | Get-ChildItem -Exclude packages.config,nuget.exe,Cake.Bakery | 163 | Remove-Item -Recurse 164 | } 165 | 166 | Write-Verbose -Message "Restoring tools from NuGet..." 167 | $NuGetOutput = Invoke-Expression "&`"$NUGET_EXE`" install -ExcludeVersion -OutputDirectory `"$TOOLS_DIR`"" 168 | 169 | if ($LASTEXITCODE -ne 0) { 170 | Throw "An error occurred while restoring NuGet tools." 171 | } 172 | else 173 | { 174 | $md5Hash | Out-File $PACKAGES_CONFIG_MD5 -Encoding "ASCII" 175 | } 176 | Write-Verbose -Message ($NuGetOutput | out-string) 177 | 178 | Pop-Location 179 | } 180 | 181 | # Restore addins from NuGet 182 | if (Test-Path $ADDINS_PACKAGES_CONFIG) { 183 | Push-Location 184 | Set-Location $ADDINS_DIR 185 | 186 | Write-Verbose -Message "Restoring addins from NuGet..." 187 | $NuGetOutput = Invoke-Expression "&`"$NUGET_EXE`" install -ExcludeVersion -OutputDirectory `"$ADDINS_DIR`"" 188 | 189 | if ($LASTEXITCODE -ne 0) { 190 | Throw "An error occurred while restoring NuGet addins." 191 | } 192 | 193 | Write-Verbose -Message ($NuGetOutput | out-string) 194 | 195 | Pop-Location 196 | } 197 | 198 | # Restore modules from NuGet 199 | if (Test-Path $MODULES_PACKAGES_CONFIG) { 200 | Push-Location 201 | Set-Location $MODULES_DIR 202 | 203 | Write-Verbose -Message "Restoring modules from NuGet..." 204 | $NuGetOutput = Invoke-Expression "&`"$NUGET_EXE`" install -ExcludeVersion -OutputDirectory `"$MODULES_DIR`"" 205 | 206 | if ($LASTEXITCODE -ne 0) { 207 | Throw "An error occurred while restoring NuGet modules." 208 | } 209 | 210 | Write-Verbose -Message ($NuGetOutput | out-string) 211 | 212 | Pop-Location 213 | } 214 | 215 | # Make sure that Cake has been installed. 216 | if (!(Test-Path $CAKE_EXE)) { 217 | Throw "Could not find Cake.exe at $CAKE_EXE" 218 | } 219 | 220 | 221 | 222 | # Build Cake arguments 223 | $cakeArguments = @("$Script"); 224 | if ($Target) { $cakeArguments += "-target=$Target" } 225 | if ($Configuration) { $cakeArguments += "-configuration=$Configuration" } 226 | if ($Verbosity) { $cakeArguments += "-verbosity=$Verbosity" } 227 | if ($ShowDescription) { $cakeArguments += "-showdescription" } 228 | if ($DryRun) { $cakeArguments += "-dryrun" } 229 | if ($Experimental) { $cakeArguments += "-experimental" } 230 | if ($Mono) { $cakeArguments += "-mono" } 231 | $cakeArguments += $ScriptArgs 232 | 233 | # Start Cake 234 | Write-Host "Running build script..." 235 | &$CAKE_EXE $cakeArguments 236 | exit $LASTEXITCODE 237 | -------------------------------------------------------------------------------- /build/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | ########################################################################## 4 | # This is the Cake bootstrapper script for Linux and OS X. 5 | # This file was downloaded from https://github.com/cake-build/resources 6 | # Feel free to change this file to fit your needs. 7 | ########################################################################## 8 | 9 | # Define directories. 10 | SCRIPT_DIR=$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd ) 11 | TOOLS_DIR=$SCRIPT_DIR/tools 12 | ADDINS_DIR=$TOOLS_DIR/Addins 13 | MODULES_DIR=$TOOLS_DIR/Modules 14 | NUGET_EXE=$TOOLS_DIR/nuget.exe 15 | CAKE_EXE=$TOOLS_DIR/Cake/Cake.exe 16 | PACKAGES_CONFIG=$TOOLS_DIR/packages.config 17 | PACKAGES_CONFIG_MD5=$TOOLS_DIR/packages.config.md5sum 18 | ADDINS_PACKAGES_CONFIG=$ADDINS_DIR/packages.config 19 | MODULES_PACKAGES_CONFIG=$MODULES_DIR/packages.config 20 | 21 | # Define md5sum or md5 depending on Linux/OSX 22 | MD5_EXE= 23 | if [[ "$(uname -s)" == "Darwin" ]]; then 24 | MD5_EXE="md5 -r" 25 | else 26 | MD5_EXE="md5sum" 27 | fi 28 | 29 | # Define default arguments. 30 | SCRIPT="build.cake" 31 | CAKE_ARGUMENTS=() 32 | 33 | # Parse arguments. 34 | for i in "$@"; do 35 | case $1 in 36 | -s|--script) SCRIPT="$2"; shift ;; 37 | --) shift; CAKE_ARGUMENTS+=("$@"); break ;; 38 | *) CAKE_ARGUMENTS+=("$1") ;; 39 | esac 40 | shift 41 | done 42 | 43 | # Make sure the tools folder exist. 44 | if [ ! -d "$TOOLS_DIR" ]; then 45 | mkdir "$TOOLS_DIR" 46 | fi 47 | 48 | # Make sure that packages.config exist. 49 | if [ ! -f "$TOOLS_DIR/packages.config" ]; then 50 | echo "Downloading packages.config..." 51 | curl -Lsfo "$TOOLS_DIR/packages.config" https://cakebuild.net/download/bootstrapper/packages 52 | if [ $? -ne 0 ]; then 53 | echo "An error occurred while downloading packages.config." 54 | exit 1 55 | fi 56 | fi 57 | 58 | # Download NuGet if it does not exist. 59 | if [ ! -f "$NUGET_EXE" ]; then 60 | echo "Downloading NuGet..." 61 | curl -Lsfo "$NUGET_EXE" https://dist.nuget.org/win-x86-commandline/latest/nuget.exe 62 | if [ $? -ne 0 ]; then 63 | echo "An error occurred while downloading nuget.exe." 64 | exit 1 65 | fi 66 | fi 67 | 68 | # Restore tools from NuGet. 69 | pushd "$TOOLS_DIR" >/dev/null 70 | if [ ! -f "$PACKAGES_CONFIG_MD5" ] || [ "$( cat "$PACKAGES_CONFIG_MD5" | sed 's/\r$//' )" != "$( $MD5_EXE "$PACKAGES_CONFIG" | awk '{ print $1 }' )" ]; then 71 | find . -type d ! -name . ! -name 'Cake.Bakery' | xargs rm -rf 72 | fi 73 | 74 | mono "$NUGET_EXE" install -ExcludeVersion 75 | if [ $? -ne 0 ]; then 76 | echo "Could not restore NuGet tools." 77 | exit 1 78 | fi 79 | 80 | $MD5_EXE "$PACKAGES_CONFIG" | awk '{ print $1 }' >| "$PACKAGES_CONFIG_MD5" 81 | 82 | popd >/dev/null 83 | 84 | # Restore addins from NuGet. 85 | if [ -f "$ADDINS_PACKAGES_CONFIG" ]; then 86 | pushd "$ADDINS_DIR" >/dev/null 87 | 88 | mono "$NUGET_EXE" install -ExcludeVersion 89 | if [ $? -ne 0 ]; then 90 | echo "Could not restore NuGet addins." 91 | exit 1 92 | fi 93 | 94 | popd >/dev/null 95 | fi 96 | 97 | # Restore modules from NuGet. 98 | if [ -f "$MODULES_PACKAGES_CONFIG" ]; then 99 | pushd "$MODULES_DIR" >/dev/null 100 | 101 | mono "$NUGET_EXE" install -ExcludeVersion 102 | if [ $? -ne 0 ]; then 103 | echo "Could not restore NuGet modules." 104 | exit 1 105 | fi 106 | 107 | popd >/dev/null 108 | fi 109 | 110 | # Make sure that Cake has been installed. 111 | if [ ! -f "$CAKE_EXE" ]; then 112 | echo "Could not find Cake.exe at '$CAKE_EXE'." 113 | exit 1 114 | fi 115 | 116 | # Start Cake 117 | exec mono "$CAKE_EXE" $SCRIPT "${CAKE_ARGUMENTS[@]}" 118 | -------------------------------------------------------------------------------- /build/cake.config: -------------------------------------------------------------------------------- 1 | ; This is the default configuration file for Cake. 2 | ; This file was downloaded from https://github.com/cake-build/resources 3 | 4 | [Nuget] 5 | Source=https://api.nuget.org/v3/index.json 6 | UseInProcessClient=true 7 | LoadDependencies=false 8 | 9 | [Paths] 10 | Tools=./tools 11 | Addins=./tools/Addins 12 | Modules=./tools/Modules 13 | 14 | [Settings] 15 | SkipVerification=false 16 | -------------------------------------------------------------------------------- /codecov.yml: -------------------------------------------------------------------------------- 1 | coverage: 2 | range: 60...100 3 | round: down 4 | precision: 2 5 | notify: 6 | slack: 7 | default: 8 | url: $(codecov-webhook) 9 | threshold: 1% 10 | only_pulls: false 11 | branches: 12 | - master 13 | - develop 14 | -------------------------------------------------------------------------------- /src/Chronicle.Integrations.MongoDB/src/Chronicle.Integrations.MongoDB.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.1 5 | Chronicle.Integrations.MongoDB 6 | Chronicle.Integrations.MongoDB 7 | Chronicle_.Integrations.MongoDB 8 | chronicle;saga;mongodb 9 | Implementation of saga pattern for .NET Core 10 | Dariusz Pawlukiewicz 11 | https://github.com/chronicle-stack/Chronicle.Integrations.MongoDB 12 | https://github.com/chronicle-stack/Chronicle/blob/master/LICENSE 13 | https://avatars1.githubusercontent.com/u/42150754?s=200 14 | 3.1.1 15 | 3.1.1 16 | 3.1.1 17 | 3.1.1 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Chronicle.Integrations.MongoDB/src/ChronicleMongoSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Chronicle.Integrations.MongoDB 2 | { 3 | public sealed class ChronicleMongoSettings 4 | { 5 | public string ConnectionString { get; set; } 6 | public string Database { get; set; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Chronicle.Integrations.MongoDB/src/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Chronicle.Integrations.MongoDB.Persistence; 3 | using Microsoft.Extensions.DependencyInjection; 4 | using MongoDB.Driver; 5 | using Microsoft.Extensions.Configuration; 6 | using Newtonsoft.Json; 7 | 8 | namespace Chronicle.Integrations.MongoDB 9 | { 10 | public static class Extensions 11 | { 12 | private static string DeserializationError => "Could not deserialize given appsettings."; 13 | 14 | public static IChronicleBuilder UseMongoPersistence(this IChronicleBuilder builder, string appSettingsSection) 15 | { 16 | return builder.UseMongoPersistence(GetDatabase); 17 | 18 | IMongoDatabase GetDatabase(IServiceProvider serviceProvider) 19 | { 20 | var configuration = serviceProvider.GetService(); 21 | 22 | try 23 | { 24 | var settings = JsonConvert.DeserializeObject(configuration.GetSection(appSettingsSection)?.Value); 25 | var database = new MongoClient(settings.ConnectionString).GetDatabase(settings.Database); 26 | 27 | return database; 28 | } 29 | catch 30 | { 31 | throw new ChronicleException(DeserializationError); 32 | } 33 | } 34 | } 35 | 36 | public static IChronicleBuilder UseMongoPersistence(this IChronicleBuilder builder, ChronicleMongoSettings settings) 37 | { 38 | return builder.UseMongoPersistence(GetDatabase); 39 | 40 | IMongoDatabase GetDatabase(IServiceProvider serviceProvider) 41 | => new MongoClient(settings.ConnectionString).GetDatabase(settings.Database); 42 | } 43 | 44 | private static IChronicleBuilder UseMongoPersistence(this IChronicleBuilder builder, Func getDatabase) 45 | { 46 | builder.Services.AddTransient(getDatabase); 47 | builder.UseSagaLog(); 48 | builder.UseSagaStateRepository(); 49 | 50 | return builder; 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Chronicle.Integrations.MongoDB/src/Persistence/MongoSagaLog.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using MongoDB.Driver; 5 | 6 | namespace Chronicle.Integrations.MongoDB.Persistence 7 | { 8 | internal sealed class MongoSagaLog : ISagaLog 9 | { 10 | private const string CollectionName = "SagaLog"; 11 | private readonly IMongoCollection _collection; 12 | 13 | public MongoSagaLog(IMongoDatabase database) 14 | => _collection = database.GetCollection(CollectionName); 15 | 16 | public async Task> ReadAsync(SagaId id, Type type) 17 | => await _collection 18 | .Find(sld => sld.SagaId == id.Id && sld.SagaType == type.FullName) 19 | .ToListAsync(); 20 | 21 | public async Task WriteAsync(ISagaLogData message) 22 | => await _collection.InsertOneAsync(new MongoSagaLogData 23 | { 24 | SagaId = message.Id, 25 | SagaType = message.Type.FullName, 26 | Message = message.Message, 27 | CreatedAt = message.CreatedAt 28 | }); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Chronicle.Integrations.MongoDB/src/Persistence/MongoSagaLogData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reflection; 3 | using MongoDB.Bson; 4 | using MongoDB.Bson.Serialization.Attributes; 5 | 6 | namespace Chronicle.Integrations.MongoDB.Persistence 7 | { 8 | internal class MongoSagaLogData : ISagaLogData 9 | { 10 | [BsonId] 11 | [BsonRepresentation(BsonType.ObjectId)] 12 | public string MongoId { get; set; } 13 | public string SagaId { get; set; } 14 | [BsonIgnore] 15 | public SagaId Id => SagaId; 16 | public string SagaType { get; set; } 17 | public long CreatedAt { get; set; } 18 | public object Message { get; set; } 19 | Type ISagaLogData.Type => Assembly.GetEntryAssembly()?.GetType(SagaType); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/Chronicle.Integrations.MongoDB/src/Persistence/MongoSagaState.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Reflection; 4 | using MongoDB.Bson.Serialization.Attributes; 5 | 6 | namespace Chronicle.Integrations.MongoDB.Persistence 7 | { 8 | internal class MongoSagaState : ISagaState 9 | { 10 | [BsonId] 11 | [BsonElement("Id")] 12 | public string MongoId { get; set; } 13 | [BsonIgnore] 14 | public SagaId Id => MongoId; 15 | 16 | public string SagaType { get; set; } 17 | public SagaStates State { get; set; } 18 | public object Data { get; set; } 19 | 20 | Type ISagaState.Type => _type ??= AppDomain.CurrentDomain.GetAssemblies() 21 | .Select(a => a.GetType(SagaType)) 22 | .FirstOrDefault(t => t is {}); 23 | 24 | private Type _type; 25 | 26 | public void Update(SagaStates state, object data = null) 27 | { 28 | State = state; 29 | Data = data; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Chronicle.Integrations.MongoDB/src/Persistence/MongoSagaStateRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using MongoDB.Driver; 4 | 5 | namespace Chronicle.Integrations.MongoDB.Persistence 6 | { 7 | internal sealed class MongoSagaStateRepository : ISagaStateRepository 8 | { 9 | private const string CollectionName = "SagaData"; 10 | private readonly IMongoCollection _collection; 11 | 12 | public MongoSagaStateRepository(IMongoDatabase database) 13 | => _collection = database.GetCollection(CollectionName); 14 | 15 | public async Task ReadAsync(SagaId id, Type type) 16 | => await _collection 17 | .Find(sld => sld.MongoId == id.Id && sld.SagaType == type.FullName) 18 | .FirstOrDefaultAsync(); 19 | 20 | public async Task WriteAsync(ISagaState sagaState) 21 | { 22 | await _collection.DeleteOneAsync(sld => sld.MongoId == sagaState.Id.Id && sld.SagaType == sagaState.Type.FullName); 23 | await _collection.InsertOneAsync(new MongoSagaState 24 | { 25 | MongoId = sagaState.Id.Id, 26 | SagaType = sagaState.Type.FullName, 27 | State = sagaState.State, 28 | Data = sagaState.Data 29 | }); 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Chronicle.Integrations.Redis/src/ChroncileRedisSettings.cs: -------------------------------------------------------------------------------- 1 | namespace Chronicle.Integrations.Redis 2 | { 3 | public sealed class ChronicleRedisSettings 4 | { 5 | public string Configuration { get; set; } 6 | public string InstanceName { get; set; } 7 | } 8 | } -------------------------------------------------------------------------------- /src/Chronicle.Integrations.Redis/src/Chronicle.Integrations.Redis.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.1 5 | Chronicle.Integrations.Redis 6 | Chronicle.Integrations.Redis 7 | Chronicle_.Integrations.Redis 8 | chronicle;saga;redis 9 | Implementation of saga pattern for .NET Core 10 | Dariusz Pawlukiewicz 11 | https://github.com/chronicle-stack/Chronicle.Integrations.Redis 12 | https://github.com/chronicle-stack/Chronicle/blob/master/LICENSE 13 | https://avatars1.githubusercontent.com/u/42150754?s=200 14 | 2.0 15 | 2.0 16 | 2.0 17 | 2.0 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Chronicle.Integrations.Redis/src/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Chronicle.Integrations.Redis.Persistence; 3 | using Microsoft.Extensions.Caching.Distributed; 4 | using Microsoft.Extensions.Configuration; 5 | using Microsoft.Extensions.DependencyInjection; 6 | using Newtonsoft.Json; 7 | 8 | namespace Chronicle.Integrations.Redis 9 | { 10 | public static class Extensions 11 | { 12 | private static string DeserializationError => "Could not deserialize given appsettings."; 13 | 14 | public static IChronicleBuilder UseRedisPersistence(this IChronicleBuilder builder, string appSettingsSection, IConfiguration configuration) 15 | { 16 | ChronicleRedisSettings settings; 17 | try 18 | { 19 | settings = JsonConvert.DeserializeObject(configuration.GetSection(appSettingsSection)?.Value); 20 | } 21 | catch 22 | { 23 | throw new ChronicleException(DeserializationError); 24 | } 25 | return builder.ConfigureRedisPersistence(settings); 26 | } 27 | 28 | public static IChronicleBuilder UseRedisPersistence(this IChronicleBuilder builder, ChronicleRedisSettings settings) 29 | { 30 | return builder.ConfigureRedisPersistence(settings); 31 | } 32 | 33 | private static IChronicleBuilder ConfigureRedisPersistence(this IChronicleBuilder builder, ChronicleRedisSettings settings) 34 | { 35 | builder.Services.AddStackExchangeRedisCache(options => 36 | { 37 | options.Configuration = settings.Configuration; 38 | options.InstanceName = settings.InstanceName; 39 | }); 40 | builder.UseSagaLog(); 41 | builder.UseSagaStateRepository(); 42 | 43 | return builder; 44 | } 45 | } 46 | } -------------------------------------------------------------------------------- /src/Chronicle.Integrations.Redis/src/Persistence/RedisSagaLog.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | using Microsoft.Extensions.Caching.Distributed; 6 | using Newtonsoft.Json; 7 | using Newtonsoft.Json.Linq; 8 | 9 | namespace Chronicle.Integrations.Redis.Persistence 10 | { 11 | internal sealed class RedisSagaLog : ISagaLog 12 | { 13 | private readonly IDistributedCache cache; 14 | 15 | public RedisSagaLog(IDistributedCache cache) 16 | { 17 | this.cache = cache; 18 | } 19 | 20 | public async Task> ReadAsync(SagaId id, Type sagaType) 21 | { 22 | if (string.IsNullOrWhiteSpace(id)) 23 | { 24 | throw new ChronicleException($"{nameof(id)} was null."); 25 | } 26 | if (sagaType is null) 27 | { 28 | throw new ChronicleException($"{nameof(sagaType)} was null."); 29 | } 30 | 31 | var sagaLogDatas = new List(); 32 | var deserializedSagaLogDatas = new List(); 33 | var cachedSagaLogDatas = await cache.GetStringAsync(LogId(id, sagaType)); 34 | 35 | if (!string.IsNullOrWhiteSpace(cachedSagaLogDatas)) 36 | { 37 | sagaLogDatas = JsonConvert.DeserializeObject>(cachedSagaLogDatas); 38 | sagaLogDatas.ForEach(sld => 39 | { 40 | { 41 | var message = (sld.Message as JObject)?.ToObject(sld.MessageType); 42 | deserializedSagaLogDatas.Add(new RedisSagaLogData(sld.Id, sld.Type, sld.CreatedAt, message, sld.MessageType)); 43 | } 44 | }); 45 | } 46 | return deserializedSagaLogDatas; 47 | } 48 | 49 | public async Task WriteAsync(ISagaLogData logData) 50 | { 51 | if (logData is null) 52 | { 53 | throw new ChronicleException($"{nameof(logData)} was null."); 54 | } 55 | var sagaLogDatas = (await ReadAsync(logData.Id, logData.Type)).ToList(); 56 | 57 | var sagaLogData = new RedisSagaLogData(logData.Id, logData.Type, logData.CreatedAt, logData.Message, logData.Message.GetType()); 58 | 59 | sagaLogDatas.Add(sagaLogData); 60 | 61 | var serializedSagaLogDatas = JsonConvert.SerializeObject(sagaLogDatas); 62 | await cache.SetStringAsync(LogId(logData.Id, logData.Type), serializedSagaLogDatas); 63 | } 64 | 65 | public async Task DeleteAsync(SagaId sagaId, Type sagaType) 66 | { 67 | if (string.IsNullOrWhiteSpace(sagaId)) 68 | { 69 | throw new ChronicleException($"{nameof(sagaId)} was null or whitespace."); 70 | } 71 | if (sagaType is null) 72 | { 73 | throw new ChronicleException($"{nameof(sagaType)} was null."); 74 | } 75 | 76 | await cache.RemoveAsync(LogId(sagaId, sagaType)); 77 | } 78 | 79 | private string LogId(string id, Type type) => $"_log_{id}_{type.GetHashCode()}"; 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /src/Chronicle.Integrations.Redis/src/Persistence/RedisSagaLogData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Chronicle.Integrations.Redis.Persistence 5 | { 6 | internal sealed class RedisSagaLogData : ISagaLogData 7 | { 8 | public SagaId Id { get; } 9 | public Type Type { get; } 10 | public long CreatedAt { get; } 11 | public object Message { get; } 12 | public Type MessageType { get; } 13 | 14 | [JsonConstructor] 15 | public RedisSagaLogData(SagaId id, Type type, long createdAt, object message, Type messageType) 16 | { 17 | Id = id; 18 | Type = type; 19 | CreatedAt = createdAt; 20 | Message = message; 21 | MessageType = messageType; 22 | } 23 | 24 | public static ISagaLogData Create(SagaId sagaId, Type sagaType, object message) 25 | => new RedisSagaLogData(sagaId, sagaType, DateTimeOffset.Now.ToUnixTimeMilliseconds(), message, message.GetType()); 26 | } 27 | } -------------------------------------------------------------------------------- /src/Chronicle.Integrations.Redis/src/Persistence/RedisSagaState.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Newtonsoft.Json; 3 | 4 | namespace Chronicle.Integrations.Redis.Persistence 5 | { 6 | internal sealed class RedisSagaState : ISagaState 7 | { 8 | public SagaId Id { get; } 9 | public Type Type { get; } 10 | public SagaStates State { get; private set; } 11 | public object Data { get; private set; } 12 | public Type DataType { get; } 13 | 14 | [JsonConstructor] 15 | public RedisSagaState(SagaId id, Type type, SagaStates state, object data = null, Type dataType = null) 16 | => (Id, Type, State, Data, DataType) = (id, type, state, data, dataType); 17 | 18 | public static ISagaState Create(SagaId sagaId, Type sagaType, SagaStates state, object data = null, Type dataType = null) 19 | => new RedisSagaState(sagaId, sagaType, state, data, dataType); 20 | 21 | public void Update(SagaStates state, object data = null) 22 | { 23 | State = state; 24 | Data = data; 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /src/Chronicle.Integrations.Redis/src/Persistence/RedisSagaStateRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Microsoft.Extensions.Caching.Distributed; 4 | using Newtonsoft.Json; 5 | using Newtonsoft.Json.Linq; 6 | 7 | namespace Chronicle.Integrations.Redis.Persistence 8 | { 9 | internal sealed class RedisSagaStateRepository : ISagaStateRepository 10 | { 11 | private readonly IDistributedCache _cache; 12 | 13 | public RedisSagaStateRepository(IDistributedCache cache) 14 | => _cache = cache; 15 | 16 | public async Task ReadAsync(SagaId sagaId, Type sagaType) 17 | { 18 | if (string.IsNullOrWhiteSpace(sagaId)) 19 | { 20 | throw new ChronicleException($"{nameof(sagaId)} was null or whitespace."); 21 | } 22 | if (sagaType is null) 23 | { 24 | throw new ChronicleException($"{nameof(sagaType)} was null."); 25 | } 26 | 27 | RedisSagaState state = null; 28 | var cachedSagaState = await _cache.GetStringAsync(StateId(sagaId, sagaType)); 29 | 30 | if (!string.IsNullOrWhiteSpace(cachedSagaState)) 31 | { 32 | state = JsonConvert.DeserializeObject(cachedSagaState); 33 | state.Update(state.State, (state.Data as JObject)?.ToObject(state.DataType)); 34 | } 35 | return state; 36 | } 37 | 38 | public async Task WriteAsync(ISagaState state) 39 | { 40 | if (state is null) 41 | { 42 | throw new ChronicleException($"{nameof(state)} was null."); 43 | } 44 | 45 | var sagaState = new RedisSagaState(state.Id, state.Type, state.State, state.Data, state.Data.GetType()); 46 | 47 | 48 | var serializedSagaState = JsonConvert.SerializeObject(sagaState); 49 | await _cache.SetStringAsync(StateId(state.Id, state.Type), serializedSagaState); 50 | } 51 | 52 | public async Task DeleteAsync(SagaId sagaId, Type sagaType) 53 | { 54 | if (string.IsNullOrWhiteSpace(sagaId)) 55 | { 56 | throw new ChronicleException($"{nameof(sagaId)} was null or whitespace."); 57 | } 58 | if (sagaType is null) 59 | { 60 | throw new ChronicleException($"{nameof(sagaType)} was null."); 61 | } 62 | await _cache.RemoveAsync(StateId(sagaId, sagaType)); 63 | } 64 | 65 | private string StateId(string id, Type type) => $"_state_{id}_{type.GetHashCode()}"; 66 | } 67 | } -------------------------------------------------------------------------------- /src/Chronicle.Tests/Builders/ChronicleBuilderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Chronicle.Builders; 5 | using Chronicle.Persistence; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Shouldly; 8 | using Xunit; 9 | 10 | namespace Chronicle.Tests.Builders 11 | { 12 | public class ChronicleBuilderTests 13 | { 14 | [Fact] 15 | public void UseInMemoryPersistence_Registers_InMemorySagaStateRepository_As_Singleton() 16 | { 17 | _builder.UseInMemoryPersistence(); 18 | 19 | _services.ShouldContain(sd => 20 | sd.ServiceType == typeof(ISagaStateRepository) && 21 | sd.ImplementationType == typeof(InMemorySagaStateRepository) && 22 | sd.Lifetime == ServiceLifetime.Singleton); 23 | } 24 | 25 | [Fact] 26 | public void UseInMemoryPersistence_Registers_InMemorySagaLog_As_Singleton() 27 | { 28 | _builder.UseInMemoryPersistence(); 29 | 30 | _services.ShouldContain(sd => 31 | sd.ServiceType == typeof(ISagaLog) && 32 | sd.ImplementationType == typeof(InMemorySagaLog) && 33 | sd.Lifetime == ServiceLifetime.Singleton); 34 | } 35 | 36 | [Fact] 37 | public void UseSagaLog_Registers_GivenImplementation_As_Transient() 38 | { 39 | _builder.UseSagaLog(); 40 | 41 | _services.ShouldContain(sd => 42 | sd.ServiceType == typeof(ISagaLog) && 43 | sd.ImplementationType == typeof(MySagaLog) && 44 | sd.Lifetime == ServiceLifetime.Transient); 45 | } 46 | 47 | [Fact] 48 | public void UseSagaStateRepository_Registers_GivenImplementation_As_Transient() 49 | { 50 | _builder.UseSagaStateRepository(); 51 | 52 | _services.ShouldContain(sd => 53 | sd.ServiceType == typeof(ISagaStateRepository) && 54 | sd.ImplementationType == typeof(MySagaStateRepository) && 55 | sd.Lifetime == ServiceLifetime.Transient); 56 | } 57 | 58 | #region ARRANGE 59 | 60 | private readonly IServiceCollection _services; 61 | private readonly IChronicleBuilder _builder; 62 | 63 | public ChronicleBuilderTests() 64 | { 65 | _services = new ServiceCollection(); 66 | _builder = new ChronicleBuilder(_services); 67 | } 68 | 69 | public class MySagaLog : ISagaLog 70 | { 71 | public Task> ReadAsync(SagaId id, Type type) 72 | { 73 | throw new NotImplementedException(); 74 | } 75 | 76 | public Task WriteAsync(ISagaLogData message) 77 | { 78 | throw new NotImplementedException(); 79 | } 80 | } 81 | 82 | public class MySagaStateRepository : ISagaStateRepository 83 | { 84 | public Task ReadAsync(SagaId id, Type type) 85 | { 86 | throw new NotImplementedException(); 87 | } 88 | 89 | public Task WriteAsync(ISagaState state) 90 | { 91 | throw new NotImplementedException(); 92 | } 93 | } 94 | 95 | #endregion 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/Chronicle.Tests/Builders/SagaContextBuilderTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using Chronicle.Builders; 4 | using Shouldly; 5 | using Xunit; 6 | 7 | namespace Chronicle.Tests.Builders 8 | { 9 | public class SagaContextBuilderTests 10 | { 11 | [Fact] 12 | public void WithCorrelationId_Sets_CorrelationId_Field_With_Given_Data() 13 | { 14 | var correlationId = SagaId.NewSagaId(); 15 | 16 | var context = _builder 17 | .WithSagaId(correlationId) 18 | .Build(); 19 | 20 | context.SagaId.ShouldBe(correlationId); 21 | } 22 | 23 | [Fact] 24 | public void WithOriginator_Sets_Originator_Field_With_Given_Data() 25 | { 26 | var originator = "originator"; 27 | 28 | var context = _builder 29 | .WithOriginator(originator) 30 | .Build(); 31 | 32 | context.Originator.ShouldBe(originator); 33 | } 34 | 35 | [Fact] 36 | public void WithMetadata_Adds_SagaContextMetadata_To_List_With_Given_Values() 37 | { 38 | var key = "key"; 39 | var value = "value"; 40 | 41 | var context = _builder 42 | .WithMetadata(key, value) 43 | .Build(); 44 | 45 | context.Metadata.Count.ShouldBe(1); 46 | context.Metadata.First().Key.ShouldBe(key); 47 | context.Metadata.First().Value.ShouldBe(value); 48 | } 49 | 50 | 51 | #region ARRANGE 52 | 53 | private readonly ISagaContextBuilder _builder; 54 | 55 | public SagaContextBuilderTests() 56 | { 57 | _builder = new SagaContextBuilder(); 58 | } 59 | 60 | #endregion 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Chronicle.Tests/Chronicle.Tests.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.1 5 | false 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | -------------------------------------------------------------------------------- /src/Chronicle.Tests/Errors/CheckTests.cs: -------------------------------------------------------------------------------- 1 | using Chronicle.Errors; 2 | using Shouldly; 3 | using Xunit; 4 | 5 | namespace Chronicle.Tests.Errors 6 | { 7 | public class CheckTests 8 | { 9 | [Fact] 10 | public void Is_Does_Nothing_If_Type_Is_Expected() 11 | { 12 | var type = typeof(CheckTests); 13 | 14 | var exception = Record.Exception(() => Check.Is(type)); 15 | 16 | exception.ShouldBeNull(); 17 | } 18 | 19 | [Fact] 20 | public void Is_Throws_If_Type_Is_Not_Expected() 21 | { 22 | var type = typeof(CheckTests); 23 | 24 | var exception = Record.Exception(() => Check.Is(type)); 25 | 26 | exception.ShouldBeAssignableTo(); 27 | } 28 | 29 | [Fact] 30 | public void IsNull_Does_Nothing_If_Data_Is_NotNull() 31 | { 32 | var exception = Record.Exception(() => Check.IsNull(new object())); 33 | 34 | exception.ShouldBeNull(); 35 | } 36 | 37 | [Fact] 38 | public void IsNull_Throws_If_Data_Is_Null() 39 | { 40 | var exception = Record.Exception(() => Check.IsNull(default(CheckTests))); 41 | 42 | exception.ShouldBeAssignableTo(); 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Chronicle.Tests/Managers/SagaSeekerTests.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Reflection; 4 | using System.Threading.Tasks; 5 | using Chronicle.Managers; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using NSubstitute; 8 | using Shouldly; 9 | using Xunit; 10 | 11 | namespace Chronicle.Tests.Managers 12 | { 13 | public class SagaSeekerTests 14 | { 15 | [Fact] 16 | public void Seek_Returns_SagaActions_For_Given_Message_Type() 17 | { 18 | var serviceCollection = new ServiceCollection(); 19 | 20 | serviceCollection.AddTransient(typeof(ISagaAction), typeof(MySaga1)); 21 | serviceCollection.AddTransient(typeof(ISagaAction), typeof(MySaga2)); 22 | 23 | var serviceProvider = serviceCollection.BuildServiceProvider(); 24 | var seeker = new SagaSeeker(serviceProvider); 25 | 26 | var actions = seeker.Seek().ToList(); 27 | 28 | actions.Count().ShouldBe(2); 29 | actions.First().GetType().ShouldBe(typeof(MySaga1)); 30 | actions.Last().GetType().ShouldBe(typeof(MySaga2)); 31 | } 32 | } 33 | 34 | #region ARRANGE 35 | 36 | public class Message 37 | { 38 | } 39 | 40 | public class MySaga1 : Saga, ISagaStartAction 41 | { 42 | public Task HandleAsync(Message message, ISagaContext context) 43 | { 44 | throw new NotImplementedException(); 45 | } 46 | 47 | public Task CompensateAsync(Message message, ISagaContext context) 48 | { 49 | throw new NotImplementedException(); 50 | } 51 | } 52 | 53 | public class MySaga2 :Saga, ISagaStartAction 54 | { 55 | public Task HandleAsync(Message message, ISagaContext context) 56 | { 57 | throw new NotImplementedException(); 58 | } 59 | 60 | public Task CompensateAsync(Message message, ISagaContext context) 61 | { 62 | throw new NotImplementedException(); 63 | } 64 | } 65 | 66 | #endregion 67 | 68 | } 69 | -------------------------------------------------------------------------------- /src/Chronicle.Tests/Persistence/InMemorySagaLogTests.cs: -------------------------------------------------------------------------------- 1 | namespace Chronicle.Tests.Persistence 2 | { 3 | public class InMemorySagaLogTests 4 | { 5 | 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/Chronicle.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio 15 4 | VisualStudioVersion = 15.0.26124.0 5 | MinimumVisualStudioVersion = 15.0.26124.0 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Chronicle", "Chronicle\Chronicle.csproj", "{04B4EFDC-54CE-4ACD-9295-9029D9556958}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestApp", "TestApp\TestApp.csproj", "{27747C67-4E52-4467-8DCD-7C36D231B094}" 9 | EndProject 10 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Chronicle.Tests", "Chronicle.Tests\Chronicle.Tests.csproj", "{0B686327-6650-4589-8DC9-6FCCFDCB24DB}" 11 | EndProject 12 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Chronicle.Integrations.MongoDB", "Chronicle.Integrations.MongoDB\src\Chronicle.Integrations.MongoDB.csproj", "{DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}" 13 | EndProject 14 | Global 15 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 16 | Debug|Any CPU = Debug|Any CPU 17 | Debug|x64 = Debug|x64 18 | Debug|x86 = Debug|x86 19 | Release|Any CPU = Release|Any CPU 20 | Release|x64 = Release|x64 21 | Release|x86 = Release|x86 22 | EndGlobalSection 23 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 24 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 25 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Debug|Any CPU.Build.0 = Debug|Any CPU 26 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Debug|x64.ActiveCfg = Debug|Any CPU 27 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Debug|x64.Build.0 = Debug|Any CPU 28 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Debug|x86.ActiveCfg = Debug|Any CPU 29 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Debug|x86.Build.0 = Debug|Any CPU 30 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Release|Any CPU.ActiveCfg = Release|Any CPU 31 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Release|Any CPU.Build.0 = Release|Any CPU 32 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Release|x64.ActiveCfg = Release|Any CPU 33 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Release|x64.Build.0 = Release|Any CPU 34 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Release|x86.ActiveCfg = Release|Any CPU 35 | {04B4EFDC-54CE-4ACD-9295-9029D9556958}.Release|x86.Build.0 = Release|Any CPU 36 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 37 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Debug|Any CPU.Build.0 = Debug|Any CPU 38 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Debug|x64.ActiveCfg = Debug|Any CPU 39 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Debug|x64.Build.0 = Debug|Any CPU 40 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Debug|x86.ActiveCfg = Debug|Any CPU 41 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Debug|x86.Build.0 = Debug|Any CPU 42 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Release|Any CPU.ActiveCfg = Release|Any CPU 43 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Release|Any CPU.Build.0 = Release|Any CPU 44 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Release|x64.ActiveCfg = Release|Any CPU 45 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Release|x64.Build.0 = Release|Any CPU 46 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Release|x86.ActiveCfg = Release|Any CPU 47 | {27747C67-4E52-4467-8DCD-7C36D231B094}.Release|x86.Build.0 = Release|Any CPU 48 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 49 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Debug|Any CPU.Build.0 = Debug|Any CPU 50 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Debug|x64.ActiveCfg = Debug|Any CPU 51 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Debug|x64.Build.0 = Debug|Any CPU 52 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Debug|x86.ActiveCfg = Debug|Any CPU 53 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Debug|x86.Build.0 = Debug|Any CPU 54 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Release|Any CPU.ActiveCfg = Release|Any CPU 55 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Release|Any CPU.Build.0 = Release|Any CPU 56 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Release|x64.ActiveCfg = Release|Any CPU 57 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Release|x64.Build.0 = Release|Any CPU 58 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Release|x86.ActiveCfg = Release|Any CPU 59 | {0B686327-6650-4589-8DC9-6FCCFDCB24DB}.Release|x86.Build.0 = Release|Any CPU 60 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 61 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Debug|Any CPU.Build.0 = Debug|Any CPU 62 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Debug|x64.ActiveCfg = Debug|Any CPU 63 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Debug|x64.Build.0 = Debug|Any CPU 64 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Debug|x86.ActiveCfg = Debug|Any CPU 65 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Debug|x86.Build.0 = Debug|Any CPU 66 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Release|Any CPU.ActiveCfg = Release|Any CPU 67 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Release|Any CPU.Build.0 = Release|Any CPU 68 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Release|x64.ActiveCfg = Release|Any CPU 69 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Release|x64.Build.0 = Release|Any CPU 70 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Release|x86.ActiveCfg = Release|Any CPU 71 | {DFB344D1-3121-43F9-9E66-20BC6B0DD9CC}.Release|x86.Build.0 = Release|Any CPU 72 | EndGlobalSection 73 | GlobalSection(SolutionProperties) = preSolution 74 | HideSolutionNode = FALSE 75 | EndGlobalSection 76 | GlobalSection(ExtensibilityGlobals) = postSolution 77 | SolutionGuid = {F6DDA96B-847E-4F6D-86EC-126800155219} 78 | EndGlobalSection 79 | EndGlobal 80 | -------------------------------------------------------------------------------- /src/Chronicle/Async/KeyedLocker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading; 4 | using System.Threading.Tasks; 5 | 6 | namespace Chronicle.Async 7 | { 8 | public sealed class KeyedLocker 9 | { 10 | private static readonly Dictionary> SemaphoreSlims 11 | = new Dictionary>(); 12 | 13 | private SemaphoreSlim GetOrCreate(object key) 14 | { 15 | RefCounted item; 16 | lock (SemaphoreSlims) 17 | { 18 | if (SemaphoreSlims.TryGetValue(key, out item)) 19 | { 20 | ++item.RefCount; 21 | } 22 | else 23 | { 24 | item = new RefCounted(new SemaphoreSlim(1, 1)); 25 | SemaphoreSlims[key] = item; 26 | } 27 | } 28 | return item.Value; 29 | } 30 | 31 | public async Task LockAsync(object key) 32 | { 33 | await GetOrCreate(key).WaitAsync().ConfigureAwait(false); 34 | return new Releaser { Key = key }; 35 | } 36 | 37 | private sealed class RefCounted 38 | { 39 | public RefCounted(T value) 40 | { 41 | RefCount = 1; 42 | Value = value; 43 | } 44 | 45 | public int RefCount { get; set; } 46 | public T Value { get; } 47 | } 48 | 49 | private sealed class Releaser : IDisposable 50 | { 51 | public object Key { get; set; } 52 | 53 | public void Dispose() 54 | { 55 | RefCounted item; 56 | lock (SemaphoreSlims) 57 | { 58 | item = SemaphoreSlims[Key]; 59 | --item.RefCount; 60 | if (item.RefCount is 0) 61 | SemaphoreSlims.Remove(Key); 62 | } 63 | item.Value.Release(); 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/Chronicle/Builders/ChronicleBuilder.cs: -------------------------------------------------------------------------------- 1 | using Chronicle.Persistence; 2 | using Microsoft.Extensions.DependencyInjection; 3 | 4 | namespace Chronicle.Builders 5 | { 6 | internal class ChronicleBuilder : IChronicleBuilder 7 | { 8 | public IServiceCollection Services { get; } 9 | 10 | public ChronicleBuilder(IServiceCollection services) 11 | => Services = services; 12 | 13 | public IChronicleBuilder UseInMemoryPersistence() 14 | { 15 | Services.AddSingleton(typeof(ISagaStateRepository), typeof(InMemorySagaStateRepository)); 16 | Services.AddSingleton(typeof(ISagaLog), typeof(InMemorySagaLog)); 17 | return this; 18 | } 19 | 20 | public IChronicleBuilder UseSagaLog() where TSagaLog : ISagaLog 21 | { 22 | Services.AddTransient(typeof(ISagaLog), typeof(TSagaLog)); 23 | return this; 24 | } 25 | 26 | public IChronicleBuilder UseSagaStateRepository() where TRepository : ISagaStateRepository 27 | { 28 | Services.AddTransient(typeof(ISagaStateRepository), typeof(TRepository)); 29 | return this; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Chronicle/Builders/SagaContextBuilder.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using Chronicle.Persistence; 3 | 4 | namespace Chronicle.Builders 5 | { 6 | internal sealed class SagaContextBuilder : ISagaContextBuilder 7 | { 8 | private SagaId _sagaId; 9 | private string _originator; 10 | private readonly List _metadata; 11 | 12 | public SagaContextBuilder() 13 | => _metadata = new List(); 14 | 15 | public ISagaContextBuilder WithSagaId(SagaId sagaId) 16 | { 17 | _sagaId = sagaId; 18 | return this; 19 | } 20 | 21 | public ISagaContextBuilder WithOriginator(string originator) 22 | { 23 | _originator = originator; 24 | return this; 25 | } 26 | 27 | public ISagaContextBuilder WithMetadata(string key, object value) 28 | { 29 | var metadata = new SagaContextMetadata(key, value); 30 | _metadata.Add(metadata); 31 | return this; 32 | } 33 | 34 | public ISagaContextBuilder WithMetadata(ISagaContextMetadata sagaContextMetadata) 35 | { 36 | _metadata.Add(sagaContextMetadata); 37 | return this; 38 | } 39 | 40 | public ISagaContext Build() 41 | => SagaContext.Create(_sagaId, _originator, _metadata); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/Chronicle/Chronicle.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | netcoreapp3.1 5 | Chronicle 6 | Chronicle 7 | Chronicle_ 8 | chronicle;saga 9 | Implementation of saga pattern for .NET Core 10 | Dariusz Pawlukiewicz, Piotr Gankiewicz 11 | https://github.com/chronicle-stack/Chronicle 12 | https://github.com/chronicle-stack/Chronicle/blob/master/LICENSE 13 | https://avatars1.githubusercontent.com/u/42150754?s=200 14 | 3.2.1 15 | 3.2.1 16 | 3.2.1.0 17 | 3.2.1.0 18 | latest 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | -------------------------------------------------------------------------------- /src/Chronicle/ChronicleException.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Chronicle 4 | { 5 | public class ChronicleException : Exception 6 | { 7 | public ChronicleException(string message) : base(message) 8 | { 9 | } 10 | 11 | public ChronicleException(string message, Exception innerException) : base(message, innerException) 12 | { 13 | } 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/Chronicle/Errors/Check.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Chronicle.Errors 4 | { 5 | internal static class Check 6 | { 7 | internal static void Is(Type type, string message = null) 8 | { 9 | if (typeof(TExpected).IsAssignableFrom(type)) 10 | { 11 | return; 12 | } 13 | message = message ?? CheckErrorMessages.InvalidArgumentType; 14 | throw new ChronicleException(message); 15 | } 16 | 17 | internal static void IsNull(TData data, string message = null) where TData : class 18 | { 19 | if(data is null) 20 | { 21 | message = message ?? CheckErrorMessages.ArgumentNull; 22 | throw new ChronicleException(message); 23 | } 24 | } 25 | 26 | private static class CheckErrorMessages 27 | { 28 | public static string InvalidArgumentType = "Invalid argument type"; 29 | public static string ArgumentNull = "Argument null"; 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/Chronicle/Extensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Reflection; 5 | using Chronicle.Builders; 6 | using Chronicle.Managers; 7 | using Microsoft.Extensions.DependencyInjection; 8 | 9 | namespace Chronicle 10 | { 11 | public static class Extensions 12 | { 13 | public static IServiceCollection AddChronicle(this IServiceCollection services, Action build = null) 14 | { 15 | services.AddTransient(); 16 | services.AddTransient(); 17 | services.AddTransient(); 18 | services.AddTransient(); 19 | services.AddTransient(); 20 | 21 | var chronicleBuilder = new ChronicleBuilder(services); 22 | 23 | if (build is null) 24 | { 25 | chronicleBuilder.UseInMemoryPersistence(); 26 | } 27 | else 28 | { 29 | build(chronicleBuilder); 30 | } 31 | 32 | services.RegisterSagas(); 33 | 34 | return services; 35 | } 36 | 37 | private static void RegisterSagas(this IServiceCollection services) 38 | => services.Scan(scan => 39 | { 40 | var assemblies = AppDomain.CurrentDomain.GetAssemblies(); 41 | 42 | scan 43 | .FromAssemblies(assemblies) 44 | .AddClasses(classes => classes.AssignableTo(typeof(ISaga))) 45 | .As(t => t 46 | .GetTypeInfo() 47 | .GetInterfaces(includeInherited: false)) 48 | .WithTransientLifetime(); 49 | }); 50 | 51 | private static IEnumerable GetInterfaces(this Type type, bool includeInherited) 52 | { 53 | if (includeInherited || type.BaseType is null) 54 | { 55 | return type.GetInterfaces(); 56 | } 57 | 58 | return type.GetInterfaces().Except(type.BaseType.GetInterfaces()); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Chronicle/IChronicleBuilder.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.Extensions.DependencyInjection; 2 | 3 | namespace Chronicle 4 | { 5 | public interface IChronicleBuilder 6 | { 7 | IServiceCollection Services { get; } 8 | IChronicleBuilder UseInMemoryPersistence(); 9 | IChronicleBuilder UseSagaLog() where TSagaLog : ISagaLog; 10 | IChronicleBuilder UseSagaStateRepository() where TRepository : ISagaStateRepository; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Chronicle/ISaga.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace Chronicle 5 | { 6 | public interface ISaga 7 | { 8 | SagaId Id { get; } 9 | SagaStates State { get; } 10 | void Complete(); 11 | Task CompleteAsync(); 12 | void Reject(Exception innerException = null); 13 | Task RejectAsync(Exception innerException = null); 14 | void Initialize(SagaId id, SagaStates state); 15 | SagaId ResolveId(object message, ISagaContext context); 16 | } 17 | 18 | public interface ISaga : ISaga where TData : class 19 | { 20 | TData Data { get; } 21 | void Initialize(SagaId id, SagaStates states, TData data); 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/Chronicle/ISagaAction.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace Chronicle 4 | { 5 | public interface ISagaAction 6 | { 7 | Task HandleAsync(TMessage message, ISagaContext context); 8 | Task CompensateAsync(TMessage message, ISagaContext context); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Chronicle/ISagaContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Chronicle 4 | { 5 | public interface ISagaContext 6 | { 7 | SagaId SagaId { get; } 8 | string Originator { get; } 9 | IReadOnlyCollection Metadata { get; } 10 | ISagaContextMetadata GetMetadata(string key); 11 | bool TryGetMetadata(string key, out ISagaContextMetadata metadata); 12 | SagaContextError SagaContextError { get; set; } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Chronicle/ISagaContextBuilder.cs: -------------------------------------------------------------------------------- 1 | namespace Chronicle 2 | { 3 | public interface ISagaContextBuilder 4 | { 5 | ISagaContextBuilder WithSagaId(SagaId sagaId); 6 | ISagaContextBuilder WithOriginator(string originator); 7 | ISagaContextBuilder WithMetadata(string key, object value); 8 | ISagaContextBuilder WithMetadata(ISagaContextMetadata sagaContextMetadata); 9 | ISagaContext Build(); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Chronicle/ISagaContextMetadata.cs: -------------------------------------------------------------------------------- 1 | namespace Chronicle 2 | { 3 | public interface ISagaContextMetadata 4 | { 5 | string Key { get; } 6 | object Value { get; } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/Chronicle/ISagaCoordinator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace Chronicle 5 | { 6 | public interface ISagaCoordinator 7 | { 8 | Task ProcessAsync(TMessage message, ISagaContext context = null) where TMessage : class; 9 | 10 | Task ProcessAsync(TMessage message, Func onCompleted = null, 11 | Func onRejected = null, ISagaContext context = null) where TMessage : class; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Chronicle/ISagaLog.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | 5 | namespace Chronicle 6 | { 7 | public interface ISagaLog 8 | { 9 | Task> ReadAsync(SagaId id, Type type); 10 | Task WriteAsync(ISagaLogData message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Chronicle/ISagaLogData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Chronicle 4 | { 5 | public interface ISagaLogData 6 | { 7 | SagaId Id { get; } 8 | Type Type { get; } 9 | long CreatedAt { get; } 10 | object Message { get; } 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/Chronicle/ISagaStartAction.cs: -------------------------------------------------------------------------------- 1 | namespace Chronicle 2 | { 3 | public interface ISagaStartAction : ISagaAction 4 | { 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /src/Chronicle/ISagaState.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Chronicle 4 | { 5 | public interface ISagaState 6 | { 7 | SagaId Id { get; } 8 | Type Type { get; } 9 | SagaStates State { get; } 10 | object Data { get; } 11 | void Update(SagaStates state, object data = null); 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Chronicle/ISagaStateRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace Chronicle 5 | { 6 | public interface ISagaStateRepository 7 | { 8 | Task ReadAsync(SagaId id, Type type); 9 | Task WriteAsync(ISagaState state); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Chronicle/Managers/ISagaInitializer.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace Chronicle.Managers 4 | { 5 | internal interface ISagaInitializer 6 | { 7 | Task<(bool isInitialized, ISagaState state)> TryInitializeAsync(ISaga saga, SagaId id, TMessage _); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Chronicle/Managers/ISagaPostProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace Chronicle.Managers 5 | { 6 | internal interface ISagaPostProcessor 7 | { 8 | Task ProcessAsync(ISaga saga, TMessage message, ISagaContext context, 9 | Func onCompleted, Func onRejected); 10 | } 11 | } -------------------------------------------------------------------------------- /src/Chronicle/Managers/ISagaProcessor.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | 3 | namespace Chronicle.Managers 4 | { 5 | internal interface ISagaProcessor 6 | { 7 | Task ProcessAsync(ISaga saga, TMessage message, ISagaState state, 8 | ISagaContext context) where TMessage : class; 9 | } 10 | } -------------------------------------------------------------------------------- /src/Chronicle/Managers/ISagaSeeker.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | 3 | namespace Chronicle.Managers 4 | { 5 | internal interface ISagaSeeker 6 | { 7 | IEnumerable> Seek(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Chronicle/Managers/SagaCoordinator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Chronicle.Async; 5 | 6 | namespace Chronicle.Managers 7 | { 8 | internal sealed class SagaCoordinator : ISagaCoordinator 9 | { 10 | private readonly ISagaSeeker _seeker; 11 | private readonly ISagaInitializer _initializer; 12 | private readonly ISagaProcessor _processor; 13 | private readonly ISagaPostProcessor _postProcessor; 14 | private static readonly KeyedLocker Locker = new KeyedLocker(); 15 | 16 | public SagaCoordinator(ISagaSeeker seeker, ISagaInitializer initializer, ISagaProcessor processor, 17 | ISagaPostProcessor postProcessor) 18 | { 19 | _seeker = seeker; 20 | _initializer = initializer; 21 | _processor = processor; 22 | _postProcessor = postProcessor; 23 | } 24 | 25 | public Task ProcessAsync(TMessage message, ISagaContext context = null) where TMessage : class 26 | => ProcessAsync(message: message, onCompleted: null, onRejected: null, context: context); 27 | 28 | public async Task ProcessAsync(TMessage message, Func onCompleted = null, 29 | Func onRejected = null, ISagaContext context = null) where TMessage : class 30 | { 31 | var actions = _seeker.Seek().ToList(); 32 | 33 | Task EmptyHook(TMessage m, ISagaContext ctx) => Task.CompletedTask; 34 | onCompleted ??= EmptyHook; 35 | onRejected ??= EmptyHook; 36 | 37 | var sagaTasks = actions 38 | .Select(action => ProcessAsync(message, action, onCompleted, onRejected, context)) 39 | .ToList(); 40 | 41 | await Task.WhenAll(sagaTasks); 42 | } 43 | 44 | private async Task ProcessAsync(TMessage message, ISagaAction action, 45 | Func onCompleted, Func onRejected, 46 | ISagaContext context = null) where TMessage : class 47 | { 48 | context ??= SagaContext.Empty; 49 | var saga = (ISaga)action; 50 | var id = saga.ResolveId(message, context); 51 | 52 | using (await Locker.LockAsync(id)) 53 | { 54 | var (isInitialized, state) = await _initializer.TryInitializeAsync(saga, id, message); 55 | 56 | if (!isInitialized) 57 | { 58 | return; 59 | } 60 | 61 | await _processor.ProcessAsync(saga, message, state, context); 62 | await _postProcessor.ProcessAsync(saga, message, context, onCompleted, onRejected); 63 | } 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/Chronicle/Managers/SagaInitializer.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Chronicle.Persistence; 4 | using Chronicle.Utils; 5 | 6 | namespace Chronicle.Managers 7 | { 8 | internal sealed class SagaInitializer : ISagaInitializer 9 | { 10 | private readonly ISagaStateRepository _repository; 11 | 12 | public SagaInitializer(ISagaStateRepository repository) 13 | { 14 | _repository = repository; 15 | } 16 | 17 | public async Task<(bool isInitialized, ISagaState state)> TryInitializeAsync(ISaga saga, SagaId id, TMessage _) 18 | { 19 | var action = (ISagaAction)saga; 20 | var sagaType = saga.GetType(); 21 | var dataType = saga.GetSagaDataType(); 22 | 23 | var state = await _repository.ReadAsync(id, sagaType).ConfigureAwait(false); 24 | 25 | if (state is null) 26 | { 27 | if (!(action is ISagaStartAction)) 28 | { 29 | return (false, default); 30 | } 31 | 32 | state = CreateSagaState(id, sagaType, dataType); 33 | } 34 | else if (state.State is SagaStates.Rejected) 35 | { 36 | return (false, default);; 37 | } 38 | 39 | InitializeSaga(saga, id, state); 40 | 41 | return (true, state); 42 | } 43 | 44 | private static ISagaState CreateSagaState(SagaId id, Type sagaType, Type dataType) 45 | { 46 | var sagaData = dataType != null ? Activator.CreateInstance(dataType) : null; 47 | return SagaState.Create(id, sagaType, SagaStates.Pending, sagaData); 48 | } 49 | 50 | private void InitializeSaga(ISaga saga, SagaId id, ISagaState state) 51 | { 52 | if (state.Data is null) 53 | { 54 | saga.Initialize(id, state.State); 55 | } 56 | else 57 | { 58 | saga.InvokeGeneric(nameof(ISaga.Initialize), id, state.State, state.Data); 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Chronicle/Managers/SagaPostProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using System.Threading.Tasks; 4 | using Chronicle.Utils; 5 | 6 | namespace Chronicle.Managers 7 | { 8 | internal sealed class SagaPostProcessor : ISagaPostProcessor 9 | { 10 | private readonly ISagaLog _log; 11 | 12 | public SagaPostProcessor(ISagaLog log) 13 | { 14 | _log = log; 15 | } 16 | 17 | public async Task ProcessAsync(ISaga saga, TMessage message, ISagaContext context, 18 | Func onCompleted, Func onRejected) 19 | { 20 | var sagaType = saga.GetType(); 21 | 22 | switch (saga.State) 23 | { 24 | case SagaStates.Rejected: 25 | await onRejected(message, context); 26 | await CompensateAsync(saga, sagaType, context); 27 | break; 28 | case SagaStates.Completed: 29 | await onCompleted(message, context); 30 | break; 31 | } 32 | } 33 | 34 | private async Task CompensateAsync(ISaga saga, Type sagaType, ISagaContext context) 35 | { 36 | var sagaLogs = await _log.ReadAsync(saga.Id, sagaType); 37 | sagaLogs.OrderByDescending(l => l.CreatedAt) 38 | .Select(l => l.Message) 39 | .ToList() 40 | .ForEach(async message => 41 | { 42 | await ((Task)saga.InvokeGeneric(nameof(ISagaAction.CompensateAsync), message, context)) 43 | .ConfigureAwait(false); 44 | }); 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/Chronicle/Managers/SagaProcessor.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Chronicle.Persistence; 4 | 5 | namespace Chronicle.Managers 6 | { 7 | internal sealed class SagaProcessor : ISagaProcessor 8 | { 9 | private readonly ISagaStateRepository _repository; 10 | private readonly ISagaLog _log; 11 | 12 | public SagaProcessor(ISagaStateRepository repository, ISagaLog log) 13 | { 14 | _repository = repository; 15 | _log = log; 16 | } 17 | 18 | public async Task ProcessAsync(ISaga saga, TMessage message, ISagaState state, 19 | ISagaContext context) where TMessage : class 20 | { 21 | var action = (ISagaAction)saga; 22 | 23 | try 24 | { 25 | await action.HandleAsync(message, context); 26 | } 27 | catch (Exception ex) 28 | { 29 | context.SagaContextError = new SagaContextError(ex); 30 | 31 | if (!(saga.State is SagaStates.Rejected)) 32 | { 33 | saga.Reject(ex); 34 | } 35 | } 36 | finally 37 | { 38 | await UpdateSagaAsync(message, saga, state); 39 | } 40 | } 41 | 42 | private async Task UpdateSagaAsync(TMessage message, ISaga saga, ISagaState state) 43 | where TMessage : class 44 | { 45 | var sagaType = saga.GetType(); 46 | 47 | var updatedSagaData = sagaType.GetProperty(nameof(ISaga.Data))?.GetValue(saga); 48 | 49 | state.Update(saga.State, updatedSagaData); 50 | var logData = SagaLogData.Create(saga.Id, sagaType, message); 51 | 52 | var persistenceTasks = new [] 53 | { 54 | _repository.WriteAsync(state), 55 | _log.WriteAsync(logData) 56 | }; 57 | 58 | await Task.WhenAll(persistenceTasks).ConfigureAwait(false); 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/Chronicle/Managers/SagaSeeker.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using Microsoft.Extensions.DependencyInjection; 5 | 6 | namespace Chronicle.Managers 7 | { 8 | internal sealed class SagaSeeker : ISagaSeeker 9 | { 10 | private readonly IServiceProvider _serviceProvider; 11 | 12 | public SagaSeeker(IServiceProvider serviceProvider) 13 | => _serviceProvider = serviceProvider; 14 | 15 | public IEnumerable> Seek() 16 | => _serviceProvider.GetService>>() 17 | .Union(_serviceProvider.GetService>>()) 18 | .GroupBy(s => s.GetType()) 19 | .Select(g => g.First()) 20 | .Distinct(); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Chronicle/Persistence/InMemorySagaLog.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Chronicle.Persistence 7 | { 8 | internal class InMemorySagaLog : ISagaLog 9 | { 10 | private readonly List _sagaLog; 11 | 12 | public InMemorySagaLog() 13 | => _sagaLog = new List(); 14 | 15 | public Task> ReadAsync(SagaId id, Type type) 16 | => Task.FromResult(_sagaLog.Where(sld => sld.Id == id && sld.Type == type)); 17 | 18 | public async Task WriteAsync(ISagaLogData message) 19 | { 20 | _sagaLog.Add(message); 21 | await Task.CompletedTask; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Chronicle/Persistence/InMemorySagaStateRepository.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading.Tasks; 5 | 6 | namespace Chronicle.Persistence 7 | { 8 | internal class InMemorySagaStateRepository : ISagaStateRepository 9 | { 10 | private readonly List _repository; 11 | 12 | public InMemorySagaStateRepository() => _repository = new List(); 13 | 14 | public Task ReadAsync(SagaId id, Type type) 15 | => Task.FromResult(_repository.FirstOrDefault(s => s.Id == id && s.Type == type)); 16 | 17 | public async Task WriteAsync(ISagaState state) 18 | { 19 | var sagaDataToUpdate = await ReadAsync(state.Id, state.Type); 20 | 21 | _repository.Remove(sagaDataToUpdate); 22 | _repository.Add(state); 23 | 24 | await Task.CompletedTask; 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Chronicle/Persistence/SagaContextMetadata.cs: -------------------------------------------------------------------------------- 1 | namespace Chronicle.Persistence 2 | { 3 | internal sealed class SagaContextMetadata : ISagaContextMetadata 4 | { 5 | public string Key { get; } 6 | public object Value { get; } 7 | 8 | public SagaContextMetadata(string key, object value) 9 | { 10 | Key = key; 11 | Value = value; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Chronicle/Persistence/SagaLogData.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Chronicle.Utils; 3 | 4 | namespace Chronicle.Persistence 5 | { 6 | internal class SagaLogData : ISagaLogData 7 | { 8 | public SagaId Id { get; } 9 | public Type Type { get; } 10 | public long CreatedAt { get; } 11 | public object Message { get; } 12 | 13 | private SagaLogData(SagaId sagaId, Type sagaType, long createdAt, object message) 14 | => (Id, Type, CreatedAt, Message) = (sagaId, sagaType, createdAt, message); 15 | 16 | public static ISagaLogData Create(SagaId sagaId, Type sagaType, object message) 17 | => new SagaLogData(sagaId, sagaType, DateTimeOffset.Now.GetTimeStamp(), message); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Chronicle/Persistence/SagaState.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Chronicle.Persistence 4 | { 5 | internal class SagaState : ISagaState 6 | { 7 | public SagaId Id { get; } 8 | public Type Type { get; } 9 | public SagaStates State { get; private set; } 10 | public object Data { get; private set; } 11 | 12 | private SagaState(SagaId id, Type type, SagaStates state, object data) 13 | => (Id, Type, State, Data) = (id, type, state, data); 14 | 15 | public static ISagaState Create(SagaId id, Type type, SagaStates state, object data = null) 16 | => new SagaState(id, type, state, data); 17 | 18 | public void Update(SagaStates state, object data = null) 19 | { 20 | State = state; 21 | Data = data; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Chronicle/Saga.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | 4 | namespace Chronicle 5 | { 6 | public abstract class Saga : ISaga 7 | { 8 | public SagaId Id { get; private set; } 9 | 10 | public SagaStates State { get; protected set; } 11 | 12 | public virtual void Initialize(SagaId id, SagaStates state) 13 | => (Id, State) = (id, state); 14 | 15 | public virtual SagaId ResolveId(object message, ISagaContext context) 16 | => context.SagaId; 17 | 18 | public virtual void Complete() 19 | => State = SagaStates.Completed; 20 | 21 | public virtual Task CompleteAsync() 22 | { 23 | Complete(); 24 | return Task.CompletedTask; 25 | } 26 | 27 | public virtual void Reject(Exception innerException = null) 28 | { 29 | State = SagaStates.Rejected; 30 | throw new ChronicleException("Saga rejection called by method", innerException); 31 | } 32 | 33 | public virtual Task RejectAsync(Exception innerException = null) 34 | { 35 | Reject(innerException); 36 | return Task.CompletedTask; 37 | } 38 | } 39 | 40 | public abstract class Saga : Saga, ISaga where TData : class, new() 41 | { 42 | public TData Data { get; protected set; } 43 | 44 | public virtual void Initialize(SagaId id, SagaStates state, TData data) 45 | { 46 | base.Initialize(id, state); 47 | Data = data; 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/Chronicle/SagaContext.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using Chronicle.Builders; 4 | 5 | namespace Chronicle 6 | { 7 | public sealed class SagaContext : ISagaContext 8 | { 9 | public SagaId SagaId { get; } 10 | public string Originator { get; } 11 | public IReadOnlyCollection Metadata { get; } 12 | public SagaContextError SagaContextError { get; set; } 13 | 14 | private SagaContext(SagaId sagaId, string originator, IEnumerable metadata) 15 | { 16 | SagaId = sagaId; 17 | Originator = originator; 18 | 19 | var areMetadataKeysUnique = metadata.GroupBy(m => m.Key).All(g => g.Count() is 1); 20 | 21 | if (!areMetadataKeysUnique) 22 | { 23 | throw new ChronicleException("Metadata keys are not unique"); 24 | } 25 | 26 | Metadata = metadata.ToList().AsReadOnly(); 27 | } 28 | 29 | public static ISagaContext Empty => 30 | new SagaContext(SagaId.NewSagaId(), string.Empty, Enumerable.Empty()); 31 | 32 | 33 | public static ISagaContext Create(SagaId sagaId, string originator, IEnumerable metadata) 34 | => new SagaContext(sagaId, originator, metadata); 35 | 36 | public static ISagaContextBuilder Create() 37 | => new SagaContextBuilder(); 38 | 39 | public ISagaContextMetadata GetMetadata(string key) 40 | => Metadata.Single(m => m.Key == key); 41 | 42 | public bool TryGetMetadata(string key, out ISagaContextMetadata metadata) 43 | { 44 | metadata = Metadata.SingleOrDefault(m => m.Key == key); 45 | return metadata != null; 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/Chronicle/SagaContextError.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Chronicle 4 | { 5 | public class SagaContextError 6 | { 7 | public Exception Exception { get; } 8 | 9 | public SagaContextError(Exception e) 10 | { 11 | Exception = e; 12 | } 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/Chronicle/SagaId.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Chronicle 4 | { 5 | public struct SagaId 6 | { 7 | public string Id { get; } 8 | 9 | private SagaId(string id) 10 | => Id = id; 11 | 12 | public static implicit operator string(SagaId sagaId) => sagaId.Id; 13 | 14 | public static implicit operator SagaId(string sagaId) 15 | => new SagaId(sagaId); 16 | 17 | public static SagaId NewSagaId() 18 | => new SagaId(Guid.NewGuid().ToString()); 19 | 20 | public override string ToString() => Id; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/Chronicle/SagaStates.cs: -------------------------------------------------------------------------------- 1 | namespace Chronicle 2 | { 3 | public enum SagaStates : byte 4 | { 5 | Pending = 0, 6 | Completed = 1, 7 | Rejected = 2, 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Chronicle/Testing/InternalTesting.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | [assembly: InternalsVisibleTo("Chronicle.Tests")] 3 | [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] 4 | -------------------------------------------------------------------------------- /src/Chronicle/Utils/DateTimeExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Chronicle.Utils 4 | { 5 | internal static class DateTimeExtensions 6 | { 7 | internal static long GetTimeStamp(this DateTimeOffset dateTime) 8 | => dateTime.ToUnixTimeMilliseconds(); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/Chronicle/Utils/SagaExtensions.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | 4 | namespace Chronicle.Utils 5 | { 6 | internal static class Extensions 7 | { 8 | public static Type GetSagaDataType(this ISaga saga) 9 | => saga 10 | .GetType() 11 | .GetInterfaces() 12 | .FirstOrDefault(i => i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ISaga<>)) 13 | ?.GetGenericArguments() 14 | .FirstOrDefault(); 15 | 16 | public static object InvokeGeneric(this ISaga saga, string method, params object[] args) 17 | => saga 18 | .GetType() 19 | .GetMethod(method, args.Select(arg => arg.GetType()).ToArray()) 20 | ?.Invoke(saga, args); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/TestApp/Program.cs: -------------------------------------------------------------------------------- 1 | using Microsoft.AspNetCore.Hosting; 2 | using Microsoft.Extensions.Hosting; 3 | 4 | namespace TestApp 5 | { 6 | public class Program 7 | { 8 | public static void Main(string[] args) 9 | { 10 | CreateWebHostBuilder(args).Build().Run(); 11 | } 12 | 13 | public static IHostBuilder CreateWebHostBuilder(string[] args) => 14 | Host.CreateDefaultBuilder(args) 15 | .ConfigureWebHostDefaults(webBuilder => 16 | { 17 | webBuilder 18 | .UseKestrel() 19 | .UseStartup(); 20 | }); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/TestApp/SampleSaga.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Chronicle; 4 | 5 | namespace TestApp 6 | { 7 | public class Message1 8 | { 9 | public string Text { get; set; } 10 | } 11 | 12 | public class Message2 13 | { 14 | public string Text { get; set; } 15 | } 16 | 17 | public class SagaData 18 | { 19 | public bool IsMessage1 { get; set; } 20 | public bool IsMessage2 { get; set; } 21 | } 22 | 23 | public class SampleSaga : Saga, ISagaStartAction, ISagaAction 24 | { 25 | 26 | public Task HandleAsync(Message2 message, ISagaContext context) 27 | { 28 | Data.IsMessage2 = true; 29 | Console.WriteLine("M2 reached!"); 30 | CompleteSaga(); 31 | return Task.CompletedTask; 32 | } 33 | 34 | public Task HandleAsync(Message1 message, ISagaContext context) 35 | { 36 | Data.IsMessage1 = true; 37 | Console.WriteLine("M1 reached!"); 38 | CompleteSaga(); 39 | return Task.CompletedTask; 40 | } 41 | 42 | public Task CompensateAsync(Message1 message, ISagaContext context) 43 | { 44 | Console.BackgroundColor = ConsoleColor.Red; 45 | Console.WriteLine($"COMPANSATE M1 with message: {message.Text}"); 46 | return Task.CompletedTask; 47 | } 48 | 49 | public Task CompensateAsync(Message2 message, ISagaContext context) 50 | { 51 | Console.BackgroundColor = ConsoleColor.Blue; 52 | Console.WriteLine($"COMPANSATE M2 with message: {message.Text}"); 53 | 54 | return Task.CompletedTask; 55 | } 56 | 57 | private void CompleteSaga() 58 | { 59 | if(Data.IsMessage1 && Data.IsMessage2) 60 | { 61 | Complete(); 62 | Console.BackgroundColor = ConsoleColor.Green; 63 | Console.WriteLine("SAGA COMPLETED"); 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/TestApp/Startup.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using System.Threading.Tasks.Sources; 4 | using Microsoft.AspNetCore.Builder; 5 | using Microsoft.AspNetCore.Hosting; 6 | using Microsoft.Extensions.DependencyInjection; 7 | using Chronicle; 8 | 9 | namespace TestApp 10 | { 11 | public class Startup 12 | { 13 | public void ConfigureServices(IServiceCollection services) 14 | { 15 | services.AddChronicle(); 16 | } 17 | 18 | public void Configure(IApplicationBuilder app, IWebHostEnvironment env) 19 | { 20 | var coordinator = app.ApplicationServices.GetService(); 21 | 22 | var context = SagaContext 23 | .Create() 24 | .WithSagaId(SagaId.NewSagaId()) 25 | .WithOriginator("Test") 26 | .WithMetadata("key", "lulz") 27 | .Build(); 28 | 29 | var context2 = SagaContext 30 | .Create() 31 | .WithSagaId(SagaId.NewSagaId()) 32 | .WithOriginator("Test") 33 | .WithMetadata("key", "lulz") 34 | .Build(); 35 | 36 | coordinator.ProcessAsync(new Message1 { Text = "This message will be used one day..." }, context); 37 | 38 | coordinator.ProcessAsync( new Message2 { Text = "But this one will be printed first! (We compensate from the end to beggining of the log)" }, 39 | onCompleted: (m, ctx) => 40 | { 41 | Console.WriteLine("My work is done"); 42 | return Task.CompletedTask; 43 | }, 44 | context: context); 45 | 46 | Console.ReadLine(); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/TestApp/TestApp.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | netcoreapp3.1 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | --------------------------------------------------------------------------------