diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ded96b2 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,5 @@ +root = true + +[*.cs,*.ps1] +indent_style = space +indent_size = 4 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3853718 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,22 @@ +# Auto detect text files and perform LF normalization +* text=unset + +# Custom for Visual Studio +*.cs diff=csharp +*.sln merge=union +*.csproj merge=union +*.vbproj merge=union +*.fsproj merge=union +*.dbproj merge=union + +# Standard to msysgit +*.doc diff=astextplain +*.DOC diff=astextplain +*.docx diff=astextplain +*.DOCX diff=astextplain +*.dot diff=astextplain +*.DOT diff=astextplain +*.pdf diff=astextplain +*.PDF diff=astextplain +*.rtf diff=astextplain +*.RTF diff=astextplain diff --git a/.nuget/NuGet.exe b/.nuget/NuGet.exe new file mode 100644 index 0000000..9cba6ed Binary files /dev/null and b/.nuget/NuGet.exe differ diff --git a/.nuget/packages.config b/.nuget/packages.config new file mode 100644 index 0000000..26fa2dc --- /dev/null +++ b/.nuget/packages.config @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/Hangfire.Mono.sln b/Hangfire.Mono.sln new file mode 100644 index 0000000..313581d --- /dev/null +++ b/Hangfire.Mono.sln @@ -0,0 +1,57 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 2012 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hangfire.Core", "src\Hangfire.Core\Hangfire.Core.csproj", "{C995EA9E-56EE-4951-8260-D94260A7F4C2}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hangfire.SqlServer", "src\Hangfire.SqlServer\Hangfire.SqlServer.csproj", "{A523C0E3-097D-4869-977F-15A717EA3E83}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hangfire.SqlServer.Msmq", "src\Hangfire.SqlServer.Msmq\Hangfire.SqlServer.Msmq.csproj", "{762BE479-0AEC-47E0-8F9C-34FA54641749}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{9FC4F973-0AA1-4E6E-AF9A-561629BE50EC}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hangfire.Core.Tests", "tests\Hangfire.Core.Tests\Hangfire.Core.Tests.csproj", "{E13C3543-39A3-475C-BB43-2E311E634843}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hangfire.SqlServer.RabbitMq", "src\Hangfire.SqlServer.RabbitMq\Hangfire.SqlServer.RabbitMq.csproj", "{4CC51F69-0311-4485-B7DE-9ECAB3A1B5E5}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hangfire.SqlServer.RabbitMq.Tests", "tests\Hangfire.SqlServer.RabbitMq.Tests\Hangfire.SqlServer.RabbitMq.Tests.csproj", "{2654D751-33D5-411D-A187-D057FED9CD25}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C995EA9E-56EE-4951-8260-D94260A7F4C2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C995EA9E-56EE-4951-8260-D94260A7F4C2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C995EA9E-56EE-4951-8260-D94260A7F4C2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C995EA9E-56EE-4951-8260-D94260A7F4C2}.Release|Any CPU.Build.0 = Release|Any CPU + {A523C0E3-097D-4869-977F-15A717EA3E83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A523C0E3-097D-4869-977F-15A717EA3E83}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A523C0E3-097D-4869-977F-15A717EA3E83}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A523C0E3-097D-4869-977F-15A717EA3E83}.Release|Any CPU.Build.0 = Release|Any CPU + {762BE479-0AEC-47E0-8F9C-34FA54641749}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {762BE479-0AEC-47E0-8F9C-34FA54641749}.Debug|Any CPU.Build.0 = Debug|Any CPU + {762BE479-0AEC-47E0-8F9C-34FA54641749}.Release|Any CPU.ActiveCfg = Release|Any CPU + {762BE479-0AEC-47E0-8F9C-34FA54641749}.Release|Any CPU.Build.0 = Release|Any CPU + {E13C3543-39A3-475C-BB43-2E311E634843}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {E13C3543-39A3-475C-BB43-2E311E634843}.Debug|Any CPU.Build.0 = Debug|Any CPU + {E13C3543-39A3-475C-BB43-2E311E634843}.Release|Any CPU.ActiveCfg = Release|Any CPU + {E13C3543-39A3-475C-BB43-2E311E634843}.Release|Any CPU.Build.0 = Release|Any CPU + {4CC51F69-0311-4485-B7DE-9ECAB3A1B5E5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4CC51F69-0311-4485-B7DE-9ECAB3A1B5E5}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4CC51F69-0311-4485-B7DE-9ECAB3A1B5E5}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4CC51F69-0311-4485-B7DE-9ECAB3A1B5E5}.Release|Any CPU.Build.0 = Release|Any CPU + {2654D751-33D5-411D-A187-D057FED9CD25}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2654D751-33D5-411D-A187-D057FED9CD25}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2654D751-33D5-411D-A187-D057FED9CD25}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2654D751-33D5-411D-A187-D057FED9CD25}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {E13C3543-39A3-475C-BB43-2E311E634843} = {9FC4F973-0AA1-4E6E-AF9A-561629BE50EC} + {25909EE6-2A56-4EAC-9D0E-436FF2435E88} = {9FC4F973-0AA1-4E6E-AF9A-561629BE50EC} + {2654D751-33D5-411D-A187-D057FED9CD25} = {9FC4F973-0AA1-4E6E-AF9A-561629BE50EC} + EndGlobalSection + GlobalSection(MonoDevelopProperties) = preSolution + StartupItem = src\Hangfire.Core\Hangfire.Core.csproj + EndGlobalSection +EndGlobal diff --git a/Hangfire.Raven.sln b/Hangfire.Raven.sln new file mode 100644 index 0000000..13f30d9 --- /dev/null +++ b/Hangfire.Raven.sln @@ -0,0 +1,45 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio 14 +VisualStudioVersion = 14.0.23107.0 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ConsoleSample", "samples\ConsoleSample\ConsoleSample.csproj", "{C02BB718-2AE4-434C-8668-C894FF663FCE}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "samples", "samples", "{119DA7FA-B94C-4B63-AEC9-428EF834E0D8}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hangfire.Raven", "src\Hangfire.SqlServer\Hangfire.Raven.csproj", "{A523C0E3-097D-4869-977F-15A717EA3E83}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "tests", "tests", "{766BE831-F758-46BC-AFD3-BBEEFE0F686F}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "nuspecs", "nuspecs", "{5E687025-A525-4534-8869-CB685C7B11C4}" + ProjectSection(SolutionItems) = preProject + nuspecs\Hangfire.Raven.nuspec = nuspecs\Hangfire.Raven.nuspec + EndProjectSection +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = ".nuget", ".nuget", "{F710C9CF-69F9-4373-9B74-ABB185BF985B}" + ProjectSection(SolutionItems) = preProject + .nuget\packages.config = .nuget\packages.config + EndProjectSection +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C02BB718-2AE4-434C-8668-C894FF663FCE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C02BB718-2AE4-434C-8668-C894FF663FCE}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C02BB718-2AE4-434C-8668-C894FF663FCE}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C02BB718-2AE4-434C-8668-C894FF663FCE}.Release|Any CPU.Build.0 = Release|Any CPU + {A523C0E3-097D-4869-977F-15A717EA3E83}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A523C0E3-097D-4869-977F-15A717EA3E83}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A523C0E3-097D-4869-977F-15A717EA3E83}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A523C0E3-097D-4869-977F-15A717EA3E83}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {C02BB718-2AE4-434C-8668-C894FF663FCE} = {119DA7FA-B94C-4B63-AEC9-428EF834E0D8} + EndGlobalSection +EndGlobal diff --git a/Hangfire.sln.DotSettings b/Hangfire.sln.DotSettings new file mode 100644 index 0000000..1b8e8e4 --- /dev/null +++ b/Hangfire.sln.DotSettings @@ -0,0 +1,6 @@ + + True + True + True + <data><IncludeFilters /><ExcludeFilters><Filter ModuleMask="Hangfire.Core.Tests" ModuleVersionMask="*" ClassMask="*" FunctionMask="*" IsEnabled="True" /><Filter ModuleMask="Hangfire.SqlServer.Tests" ModuleVersionMask="*" ClassMask="*" FunctionMask="*" IsEnabled="True" /></ExcludeFilters></data> + <data /> \ No newline at end of file diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 8f71f43..0000000 --- a/LICENSE +++ /dev/null @@ -1,202 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "{}" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright {yyyy} {name of copyright owner} - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. - diff --git a/README.md b/README.md new file mode 100644 index 0000000..9bc3133 --- /dev/null +++ b/README.md @@ -0,0 +1,170 @@ +This is a work on progress! Don't use it yet. + + +Hangfire +========= + +[![Official Site](https://img.shields.io/badge/site-hangfire.io-blue.svg)](http://hangfire.io) [![Latest version](https://img.shields.io/nuget/v/Hangfire.svg)](https://www.nuget.org/packages?q=hangfire) [![Build status](https://ci.appveyor.com/api/projects/status/70m632jkycqpnsp9?svg=true)](https://ci.appveyor.com/project/odinserj/hangfire-525) [![Build Status](https://travis-ci.org/HangfireIO/Hangfire.svg)](https://travis-ci.org/HangfireIO/Hangfire) [![License LGPLv3](https://img.shields.io/badge/license-LGPLv3-green.svg)](http://www.gnu.org/licenses/lgpl-3.0.html) + +Incredibly easy way to perform **fire-and-forget**, **delayed** and **recurring jobs** inside **ASP.NET applications**. CPU and I/O intensive, long-running and short-running jobs are supported. No Windows Service / Task Scheduler required. Backed by Redis, SQL Server, SQL Azure or MSMQ. + +Hangfire provides unified programming model to handle background tasks in a **reliable way** and run them on shared hosting, dedicated hosting or in cloud. You can start with a simple setup and grow computational power for background jobs with time for these scenarios: + +- mass notifications/newsletter; +- batch import from xml, csv, json; +- creation of archives; +- firing off web hooks; +- deleting users; +- building different graphs; +- image/video processing; +- purge temporary files; +- recurring automated reports; +- database maintenance; +- *…and so on.* + +Hangfire is a .NET Framework alternative to [Resque](https://github.com/resque/resque), [Sidekiq](http://sidekiq.org), [delayed_job](https://github.com/collectiveidea/delayed_job), [Celery](http://www.celeryproject.org). + +![Hangfire Dashboard](http://hangfire.io/img/ui/dashboard-sm.png) + +Installation +------------- + +Hangfire is available as a NuGet package. So, you can install it using the NuGet Package Console window: + +``` +PM> Install-Package Hangfire +``` + +After install, update your existing [OWIN Startup](http://www.asp.net/aspnet/overview/owin-and-katana/owin-startup-class-detection) file with the following lines of code. If you do not have this class in your project or don't know what is it, please read the [Quick start](http://docs.hangfire.io/en/latest/quickstart.html) guide to learn about how to install Hangfire. + +```csharp +public void Configuration(IAppBuilder app) +{ + GlobalConfiguration.Configuration.UseSqlServerStorage(""); + + app.UseHangfireServer(); + app.UseHangfireDashboard(); +} +``` + +Usage +------ + +This is incomplete list of features, to see all of them, check the [official site](http://hangfire.io) and the [documentation](http://docs.hangfire.io). + +[**Fire-and-forget tasks**](http://docs.hangfire.io/en/latest/users-guide/background-methods/calling-methods-in-background.html) + +Enqueued background jobs are being executed inside a dedicated worker pool threads as soon as possible, shortening your request processing time. + +```csharp +BackgroundJob.Enqueue(() => Console.WriteLine("Simple!")); +``` + +[**Delayed tasks**](http://docs.hangfire.io/en/latest/users-guide/background-methods/calling-methods-with-delay.html) + +Scheduled background jobs are being executed only after given amount of time. + +```csharp +BackgroundJob.Schedule(() => Console.WriteLine("Reliable!"), TimeSpan.FromDays(7)); +``` + +[**Recurring tasks**](http://docs.hangfire.io/en/latest/users-guide/background-methods/performing-recurrent-tasks.html) + +Recurring jobs were never been simpler, just call the following method to perform any kind of recurring task using the [CRON expressions](http://en.wikipedia.org/wiki/Cron#CRON_expression). + +```csharp +RecurringJob.AddOrUpdate(() => Console.WriteLine("Transparent!"), Cron.Daily); +``` + +**Continuations** + +Continuations allow you to define complex workflows by chaining multiple background jobs together. + +```csharp +var id = BackgroundJob.Enqueue(() => Console.WriteLine("Hello, ")); +BackgroundJob.ContinueWith(id, () => Console.WriteLine("world!")); +``` + +**Process them inside a web application…** + +You can process background tasks in any OWIN compatible application frameworks, including [ASP.NET MVC](http://www.asp.net/mvc), [ASP.NET Web API](http://www.asp.net/web-api), [FubuMvc](http://fubu-project.org), [Nancy](http://nancyfx.org), etc. Forget about [AppDomain unloads, Web Garden & Web Farm issues](http://haacked.com/archive/2011/10/16/the-dangers-of-implementing-recurring-background-tasks-in-asp-net.aspx/) – Hangfire is reliable for web applications from scratch, even on shared hosting. + +```csharp +app.UseHangfireServer(); +``` + +**… or anywhere else** + +In console application, Windows Service, Azure Worker Role, etc. + +```csharp +using (new BackgroundJobServer()) +{ + Console.WriteLine("Hangfire Server started. Press ENTER to exit..."); + Console.ReadLine(); +} +``` + +Questions? Problems? +--------------------- + +Open-source project are developing more smoothly, when all discussions are held in public. + +If you have any questions, problems related to the Hangfire usage or want to discuss new features, please visit the [discussion forum](http://discuss.hangfire.io). You can sign in there using your existing Google or GitHub account, so it's very simple to start using it. + +If you've discovered a bug, please report it to the [Hangfire GitHub Issues](https://github.com/odinserj/Hangfire/issues?state=open). Detailed reports with stack traces, actual and expected behavours are welcome. + +Related Projects +----------------- + +Please see the [Extensions](http://hangfire.io/extensions.html) page on official site. + +Building the sources +--------------------- + +To build a solution and get assembly files, just run the following command. All build artifacts, including `*.pdb` files will be placed into the `build` folder. **Before proposing a pull request, please use this command to ensure everything is ok.** Btw, you can execute this command from Package Manager Console window. + +``` +build +``` + +To build NuGet packages as well as an archive file, use the `pack` command as shown below. You can find the result files in the `build` folder. + +``` +build pack +``` + +To see the full list of avalable commands, pass the `-docs` switch: + +``` +build -docs +``` + +Hangfire uses [psake](https://github.com/psake/psake) build automation tool. All psake tasks and functions defined in `psake-build.ps1` (for this project) and `psake-common.ps1` (for other Hangfire projects) files. Thanks to the psake project, they are very simple to use and modify! + +License +-------- + +Copyright © 2013-2014 Sergey Odinokov. + +This program is free software: you can redistribute it and/or modify +it under the terms of the GNU Lesser General Public License as published by +the Free Software Foundation, either version 3 of the License, or +(at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU Lesser General Public License for more details. + +You should have received a copy of the GNU Lesser General Public License +along with this program. If not, see [http://www.gnu.org/licenses/](http://www.gnu.org/licenses). + +Legal +------ + +By submitting a Pull Request, you disavow any rights or claims to any changes submitted to the Hangfire project and assign the copyright of those changes to Sergey Odinokov. + +If you cannot or do not want to reassign those rights (your employment contract for your employer may not allow this), you should not submit a PR. Open an issue and someone else can do the work. + +This is a legal way of saying "If you submit a PR to us, that code becomes ours". 99.9% of the time that's what you intend anyways; we hope it doesn't scare you away from contributing. diff --git a/content/readme.txt b/content/readme.txt new file mode 100644 index 0000000..dfb958f --- /dev/null +++ b/content/readme.txt @@ -0,0 +1,37 @@ +Please see http://docs.hangfire.io for more information on using Hangfire. The +`Hangfire` package is using SQL Server as a job storage and intended to run in +any OWIN-based web application. + +To run Hangfire in your application, update the existing OWIN Startup class or +create the file `Startup.cs` in the root folder of your application as shown +below. Please see also http://docs.hangfire.io/en/latest/quickstart.html. + +!!! DASHBOARD REQUIRES AUTH CONFIGURATION !!! + +By default, ONLY LOCAL requests are allowed to access the Dashboard. Please +see the `Configuring Dashboard authorization` section in Hangfire documentation: +http://docs.hangfire.io/en/latest/configuration/configuring-authorization.html + +Sample OWIN Startup class +-------------------------- + +using Hangfire; +using Microsoft.Owin; +using Owin; + +[assembly: OwinStartup(typeof(MyWebApplication.Startup))] + +namespace MyWebApplication +{ + public class Startup + { + public void Configuration(IAppBuilder app) + { + GlobalConfiguration.Configuration + .UseSqlServerStorage(""); + + app.UseHangfireDashboard(); + app.UseHangfireServer(); + } + } +} diff --git a/nuspecs/Hangfire.Core.nuspec b/nuspecs/Hangfire.Core.nuspec new file mode 100644 index 0000000..42a6cee --- /dev/null +++ b/nuspecs/Hangfire.Core.nuspec @@ -0,0 +1,54 @@ + + + + Hangfire.Core + 0.0.0 + Hangfire Core Components + Sergey Odinokov + odinserj + http://hangfire.io/ + false + https://raw.github.com/HangfireIO/Hangfire/master/LICENSE.md + Core components for Hangfire (background job system for ASP.NET applications). + Copyright © 2013-2015 Sergey Odinokov + Hangfire OWIN Long-Running Background Fire-And-Forget Delayed Recurring Tasks Jobs Scheduler Threading Queues + http://hangfire.io/blog/ + +1.5.0 +* Added – `IBackgroundProcess` interface that allows to implement custom background processes. +* Added – `BackgroundProcessingServer` class for creating a server instance with completely custom background processes. +* Added – Support for running multiple `BackgroundJobServer` instances in the same AppDomain without explicit server names. +* Added – `JobActivatorScope` class to support custom IoC scopes/lifetimes. +* Added – Allow to specify a custom queue for recurring jobs (by @maleet). +* Added – Allow to use CancellationToken type as a job method parameter. +* Added – It is now possible to query the storage in state applying filters. +* Added – Allow to pass custom filters to `BackgroundJobServer` via options. +* Added – Option to hide the "Back to site" link (by @dradovic). +* Changed – Server components and background processes now use `Task` with `TaskCreationOptions.LongRunning`. +* Changed – ServerIds are now generated without ServerName, server names are deprecated. +* Changed – Arguments Renderer now supports TimeSpan and DateTime (by @yngndrw). +* Changed – Minor improvements for Dashboard navigation (by @pwelter34). +* Changed – Worker does not process a job when a transition to the Processing state failed (by @yngndrw). +* Changed – `BackgroundJobClientException` is thrown instead of `CreateJobFailedException`. +* Changed – WorkerId is now auto-generated and based on a GUID. +* Changed – Use StackTraceFormatter package for marking-up stack traces (by @atifaziz). +* Changed – Updated Dashboard CSS to Twitter Bootstrap 3.3.5. +* Fixed – Prevent worker from fetching and processing a job on shutdown. +* Fixed – `TaskCanceledException` thrown from a job method leads to failed job as expected. +* Fixed – Make `DelayedJobScheduler` resilient to empty background job ids. +* Fixed – Logical error in SqlServerDistributedLockTimeout value check (by @Tom-Kelly). +* Fixed – `OverflowException` in Dashboard when job duration exceeds int.MaxValue (by @mavenius). +* Fixed – "More details" button in Dashboard changes to "Less details" when collapsed (by @pascalberger). + + + + + + + + + + + + + \ No newline at end of file diff --git a/nuspecs/Hangfire.Raven.nuspec b/nuspecs/Hangfire.Raven.nuspec new file mode 100644 index 0000000..5227744 --- /dev/null +++ b/nuspecs/Hangfire.Raven.nuspec @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/nuspecs/Hangfire.SqlServer.MSMQ.nuspec b/nuspecs/Hangfire.SqlServer.MSMQ.nuspec new file mode 100644 index 0000000..62791c8 --- /dev/null +++ b/nuspecs/Hangfire.SqlServer.MSMQ.nuspec @@ -0,0 +1,35 @@ + + + + Hangfire.SqlServer.MSMQ + 0.0.0 + Hangfire MSMQ Queues for SQL Server Storage + Sergey Odinokov + odinserj + http://hangfire.io/ + false + https://raw.github.com/HangfireIO/Hangfire/master/LICENSE.md + MSMQ queues support for SQL Server job storage implementation for Hangfire (background job system for ASP.NET applications). + Copyright © 2014-2015 Sergey Odinokov + Hangfire SqlServer MSMQ + http://hangfire.io/blog/ + +1.5.0 +* Added – Support for remote MSMQ queues through DTC transactions. + +1.4.0 +* Changed – Speed up `GetCount` method with native implementation. +* Fixed – Incorrect order of fetching when multiple queues used. + + + + + + + + + + + + + \ No newline at end of file diff --git a/nuspecs/Hangfire.SqlServer.RabbitMQ.nuspec b/nuspecs/Hangfire.SqlServer.RabbitMQ.nuspec new file mode 100644 index 0000000..5a7f2af --- /dev/null +++ b/nuspecs/Hangfire.SqlServer.RabbitMQ.nuspec @@ -0,0 +1,35 @@ + + + + Hangfire.SqlServer.RabbitMQ + 0.0.0 + Hangfire RabbitMQ Queues for SQL Server Storage + Denny Ferrassoli + dennyferra + http://hangfire.io/ + false + https://raw.github.com/HangfireIO/Hangfire/master/LICENSE.md + RabbitMQ queues support for SQL Server job storage implementation for Hangfire (background job system for ASP.NET applications). + Copyright © 2014 Denny Ferrassoli + Hangfire SqlServer RabbitMQ + http://hangfire.io/blog/ + +1.4.2 +* Fixed – Incompatibility issue with RabbitMQ.Client >= 3.4.0 (by @justmara). + +1.4.0 +* Added – Allow a URI to be used for `RabbitMQConnectionConfiguration` (by @dennyferra). + + + + + + + + + + + + + + \ No newline at end of file diff --git a/nuspecs/Hangfire.nuspec b/nuspecs/Hangfire.nuspec new file mode 100644 index 0000000..64d7384 --- /dev/null +++ b/nuspecs/Hangfire.nuspec @@ -0,0 +1,71 @@ + + + + Hangfire + 0.0.0 + Hangfire + Sergey Odinokov + odinserj + http://hangfire.io/ + false + https://raw.github.com/HangfireIO/Hangfire/master/LICENSE.md + + An easy and reliable way to perform fire-and-forget, delayed and recurring long-running, short-running, CPU or I/O intensive tasks inside ASP.NET application. No Windows Service / Task Scheduler required. Even ASP.NET is not required. + Backed by Redis, SQL Server, SQL Azure or MSMQ. This is a .NET alternative to Sidekiq, Resque and Celery. + http://hangfire.io/ + + Copyright © 2013-2015 Sergey Odinokov + Hangfire AspNet MVC OWIN SqlServer Long-Running Background Fire-And-Forget Delayed Recurring Tasks Jobs Scheduler Threading Queues + http://hangfire.io/blog/ + +1.5.0 + +Hangfire.Core +* Added – `IBackgroundProcess` interface that allows to implement custom background processes. +* Added – `BackgroundProcessingServer` class for creating a server instance with completely custom background processes. +* Added – Support for running multiple `BackgroundJobServer` instances in the same AppDomain without explicit server names. +* Added – `JobActivatorScope` class to support custom IoC scopes/lifetimes. +* Added – Allow to specify a custom queue for recurring jobs (by @maleet). +* Added – Allow to use CancellationToken type as a job method parameter. +* Added – It is now possible to query the storage in state applying filters. +* Added – Allow to pass custom filters to `BackgroundJobServer` via options. +* Added – Option to hide the "Back to site" link (by @dradovic). +* Changed – Server components and background processes now use `Task` with `TaskCreationOptions.LongRunning`. +* Changed – ServerIds are now generated without ServerName, server names are deprecated. +* Changed – Arguments Renderer now supports TimeSpan and DateTime (by @yngndrw). +* Changed – Minor improvements for Dashboard navigation (by @pwelter34). +* Changed – Worker does not process a job when a transition to the Processing state failed (by @yngndrw). +* Changed – `BackgroundJobClientException` is thrown instead of `CreateJobFailedException`. +* Changed – WorkerId is now auto-generated and based on a GUID. +* Changed – Use StackTraceFormatter package for marking-up stack traces (by @atifaziz). +* Changed – Updated Dashboard CSS to Twitter Bootstrap 3.3.5. +* Fixed – Prevent worker from fetching and processing a job on shutdown. +* Fixed – `TaskCanceledException` thrown from a job method leads to failed job as expected. +* Fixed – Make `DelayedJobScheduler` resilient to empty background job ids. +* Fixed – Logical error in SqlServerDistributedLockTimeout value check (by @Tom-Kelly). +* Fixed – `OverflowException` in Dashboard when job duration exceeds int.MaxValue (by @mavenius). +* Fixed – "More details" button in Dashboard changes to "Less details" when collapsed (by @pascalberger). + +Hangfire.SqlServer +* Added – Ability to specify the schema for SQL Server job storage (by @ryanmwright). +* Added – `TransactionTimeout` option for configuring the `TransactionScope` timeout. +* Added – `DashboardJobListLimit` option to prevent full table scans in Dashboard. +* Changed – `SqlServerDistributedLock` class is now public. +* Changed – Use `rowlock` table hint in job fetch query to prevent escalation. +* Changed – Dramatically improved page load times in Dashboard. +* Changed – Instant re-queue for background jobs even after ungraceful shutdown. +* Changed – `JobQueue.Queue` and `Server.Id` column length increased to 50 and 100 respectively (by @maleet). + +Hangfire.SqlServer.Msmq +* Added – Support for remote MSMQ queues through DTC transactions. + + + + + + + + + + + diff --git a/samples/ConsoleSample/ConsoleSample.csproj b/samples/ConsoleSample/ConsoleSample.csproj new file mode 100644 index 0000000..7f56e02 --- /dev/null +++ b/samples/ConsoleSample/ConsoleSample.csproj @@ -0,0 +1,86 @@ + + + + + Debug + AnyCPU + {C02BB718-2AE4-434C-8668-C894FF663FCE} + Exe + Properties + ConsoleSample + ConsoleSample + v4.5 + 512 + ..\ + + 89909a41 + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + ManagedMinimumRules.ruleset + false + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + false + + + + + + + ..\..\packages\Hangfire.Core.1.5.1\lib\net45\Hangfire.Core.dll + True + + + ..\..\packages\Newtonsoft.Json.5.0.1\lib\net45\Newtonsoft.Json.dll + True + + + ..\..\packages\Owin.1.0\lib\net40\Owin.dll + True + + + + + + + + + + + + + + + + + + + + + + {a523c0e3-097d-4869-977f-15a717ea3e83} + Hangfire.Raven + + + + + \ No newline at end of file diff --git a/samples/ConsoleSample/GenericServices.cs b/samples/ConsoleSample/GenericServices.cs new file mode 100644 index 0000000..c9f28dd --- /dev/null +++ b/samples/ConsoleSample/GenericServices.cs @@ -0,0 +1,12 @@ +using System; + +namespace ConsoleSample +{ + public class GenericServices + { + public void Method(TType arg1, TMethod arg2) + { + Console.WriteLine("Arg1: {0}, Arg2: {1}", arg1, arg2); + } + } +} \ No newline at end of file diff --git a/samples/ConsoleSample/Program.cs b/samples/ConsoleSample/Program.cs new file mode 100644 index 0000000..0eaf05f --- /dev/null +++ b/samples/ConsoleSample/Program.cs @@ -0,0 +1,38 @@ +using System; +using Hangfire; +using HangFire.Raven.Storage; + +namespace ConsoleSample +{ + public static class Program + { + public static int x = 0; + + public static void Main() + { + // you can use Raven Storage and specify the connection string and database name + GlobalConfiguration.Configuration + .UseColouredConsoleLogProvider() + .UseRavenStorage("http://localhost:9090", "hangfire2"); + + // you can use Raven Embedded Storage which runs in memory! + //GlobalConfiguration.Configuration + // .UseColouredConsoleLogProvider() + // .UseEmbeddedRavenStorage(); + + //you have to create an instance of background job server at least once for background jobs to run + var client = new BackgroundJobServer(); + + BackgroundJob.Enqueue(() => Console.WriteLine("Background Job: Hello, world!")); + RecurringJob.AddOrUpdate(() => test(), Cron.Minutely); + + Console.WriteLine("Press Enter to exit..."); + Console.ReadLine(); + } + + public static void test() + { + Console.WriteLine($"{x++} Cron Job: Hello, world!"); + } + } +} \ No newline at end of file diff --git a/samples/ConsoleSample/Properties/AssemblyInfo.cs b/samples/ConsoleSample/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..1a5ee35 --- /dev/null +++ b/samples/ConsoleSample/Properties/AssemblyInfo.cs @@ -0,0 +1,35 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Hangfire.Hosts.Console")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Hangfire.Hosts.Console")] +[assembly: AssemblyCopyright("Copyright © 2013")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("3c3451fc-663d-4e33-ab72-d92beae38a97")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/samples/ConsoleSample/Services.cs b/samples/ConsoleSample/Services.cs new file mode 100644 index 0000000..2977d8a --- /dev/null +++ b/samples/ConsoleSample/Services.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.IO; +using System.Threading; +using Hangfire; + +namespace ConsoleSample +{ + public class Services + { + private static readonly Random _random = new Random(); + + public void EmptyDefault() + { + } + + [Queue("critical")] + public void EmptyCritical() + { + } + + [AutomaticRetry(Attempts = 0)] + public void Error() + { + Console.WriteLine("Beginning error task..."); + throw new InvalidOperationException(null, new FileLoadException()); + } + + [Queue("critical")] + public void Random(int number) + { + int time; + lock (_random) + { + time = _random.Next(10); + } + + if (time < 5) + { + throw new Exception(); + } + + Thread.Sleep(TimeSpan.FromSeconds(5 + time)); + Console.WriteLine("Finished task: " + number); + } + + public void Cancelable(int iterationCount, IJobCancellationToken token) + { + try + { + for (var i = 1; i <= iterationCount; i++) + { + Thread.Sleep(1000); + Console.WriteLine("Performing step {0} of {1}...", i, iterationCount); + + token.ThrowIfCancellationRequested(); + } + } + catch (OperationCanceledException) + { + Console.WriteLine("Cancellation requested, exiting..."); + throw; + } + } + + [DisplayName("Name: {0}")] + public void Args(string name, int authorId, DateTime createdAt) + { + Console.WriteLine("{0}, {1}, {2}", name, authorId, createdAt); + } + + public void Custom(int id, string[] values, CustomObject objects, DayOfWeek dayOfWeek) + { + } + + public void FullArgs( + bool b, + int i, + char c, + DayOfWeek e, + string s, + TimeSpan t, + DateTime d, + CustomObject o, + string[] sa, + int[] ia, + long[] ea, + object[] na, + List sl) + { + } + + public class CustomObject + { + public int Id { get; set; } + public CustomObject[] Children { get; set; } + } + + public void Write(char character) + { + Console.Write(character); + } + + public void WriteBlankLine() + { + Console.WriteLine(); + } + } +} \ No newline at end of file diff --git a/samples/ConsoleSample/app.config b/samples/ConsoleSample/app.config new file mode 100644 index 0000000..a7c7bf1 --- /dev/null +++ b/samples/ConsoleSample/app.config @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/samples/ConsoleSample/packages.config b/samples/ConsoleSample/packages.config new file mode 100644 index 0000000..fbf019f --- /dev/null +++ b/samples/ConsoleSample/packages.config @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/samples/MvcSample/App_Start/FilterConfig.cs b/samples/MvcSample/App_Start/FilterConfig.cs new file mode 100644 index 0000000..9f2eaee --- /dev/null +++ b/samples/MvcSample/App_Start/FilterConfig.cs @@ -0,0 +1,12 @@ +using System.Web.Mvc; + +namespace MvcSample +{ + public class FilterConfig + { + public static void RegisterGlobalFilters(GlobalFilterCollection filters) + { + filters.Add(new HandleErrorAttribute()); + } + } +} \ No newline at end of file diff --git a/samples/MvcSample/App_Start/RouteConfig.cs b/samples/MvcSample/App_Start/RouteConfig.cs new file mode 100644 index 0000000..5a3433f --- /dev/null +++ b/samples/MvcSample/App_Start/RouteConfig.cs @@ -0,0 +1,19 @@ +using System.Web.Mvc; +using System.Web.Routing; + +namespace MvcSample +{ + public class RouteConfig + { + public static void RegisterRoutes(RouteCollection routes) + { + routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); + + routes.MapRoute( + name: "Default", + url: "{controller}/{action}/{id}", + defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional } + ); + } + } +} \ No newline at end of file diff --git a/samples/MvcSample/Content/Site.css b/samples/MvcSample/Content/Site.css new file mode 100644 index 0000000..0e2ab5e --- /dev/null +++ b/samples/MvcSample/Content/Site.css @@ -0,0 +1,61 @@ +body { + font-size: .85em; + font-family: "Segoe UI", Verdana, Helvetica, Sans-Serif; + color: #232323; + background-color: #fff; +} + +header, footer, nav, section { + display: block; +} + + +/* Styles for basic forms +-----------------------------------------------------------*/ +fieldset { + border: 1px solid #ddd; + padding: 0 1.4em 1.4em 1.4em; + margin: 0 0 1.5em 0; +} + +legend { + font-size: 1.2em; + font-weight: bold; +} + +textarea { + min-height: 75px; +} + +.editor-label { + margin: 1em 0 0 0; +} + +.editor-field { + margin: 0.5em 0 0 0; +} + + +/* Styles for validation helpers +-----------------------------------------------------------*/ +.field-validation-error { + color: #f00; +} + +.field-validation-valid { + display: none; +} + +.input-validation-error { + border: 1px solid #f00; + background-color: #fee; +} + +.validation-summary-errors { + font-weight: bold; + color: #f00; +} + +.validation-summary-valid { + display: none; +} diff --git a/samples/MvcSample/Global.asax b/samples/MvcSample/Global.asax new file mode 100644 index 0000000..ea55977 --- /dev/null +++ b/samples/MvcSample/Global.asax @@ -0,0 +1 @@ +<%@ Application Codebehind="Global.asax.cs" Inherits="MvcSample.MvcApplication" Language="C#" %> diff --git a/samples/MvcSample/Global.asax.cs b/samples/MvcSample/Global.asax.cs new file mode 100644 index 0000000..877a84b --- /dev/null +++ b/samples/MvcSample/Global.asax.cs @@ -0,0 +1,19 @@ +using System.Web.Mvc; +using System.Web.Routing; + +namespace MvcSample +{ + // Note: For instructions on enabling IIS6 or IIS7 classic mode, + // visit http://go.microsoft.com/?LinkId=9394801 + + public class MvcApplication : System.Web.HttpApplication + { + protected void Application_Start() + { + AreaRegistration.RegisterAllAreas(); + + FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); + RouteConfig.RegisterRoutes(RouteTable.Routes); + } + } +} \ No newline at end of file diff --git a/samples/MvcSample/MvcSample.csproj b/samples/MvcSample/MvcSample.csproj new file mode 100644 index 0000000..ce8e538 --- /dev/null +++ b/samples/MvcSample/MvcSample.csproj @@ -0,0 +1,203 @@ + + + + + Debug + AnyCPU + + + 2.0 + {FE5EAE49-2627-42B6-8C2B-EBE4D45B4D97} + {349c5851-65df-11da-9384-00065b846f21};{fae04ec0-301f-11d3-bf4b-00c04f79efbc} + Library + Properties + MvcSample + MvcSample + v4.5 + false + true + + + + + ..\ + true + + + C:\Users\odinserj\Documents\GitHub\Hangfire\Backup6\ + 4.0 + + + + 5fc7aa54 + + + true + full + false + bin\ + DEBUG;TRACE + prompt + 4 + ManagedMinimumRules.ruleset + + + pdbonly + true + bin\ + TRACE + prompt + 4 + + + + + ..\..\packages\Microsoft.Owin.3.0.1\lib\net45\Microsoft.Owin.dll + True + + + ..\..\packages\Microsoft.Owin.Host.SystemWeb.3.0.1\lib\net45\Microsoft.Owin.Host.SystemWeb.dll + True + + + ..\..\packages\Owin.1.0\lib\net40\Owin.dll + + + + + + + ..\..\packages\Microsoft.Net.Http.2.2.13\lib\net45\System.Net.Http.Extensions.dll + + + ..\..\packages\Microsoft.Net.Http.2.2.13\lib\net45\System.Net.Http.Primitives.dll + + + + + + + + + ..\..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.Helpers.dll + True + + + ..\..\packages\Microsoft.AspNet.Mvc.5.2.3\lib\net45\System.Web.Mvc.dll + True + + + ..\..\packages\Microsoft.AspNet.Razor.3.2.3\lib\net45\System.Web.Razor.dll + True + + + ..\..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.dll + True + + + ..\..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.Deployment.dll + True + + + ..\..\packages\Microsoft.AspNet.WebPages.3.2.3\lib\net45\System.Web.WebPages.Razor.dll + True + + + + + + + + + + + + True + ..\..\packages\Microsoft.Web.Infrastructure.1.0.0.0\lib\net40\Microsoft.Web.Infrastructure.dll + + + + + + + + + + + Global.asax + + + + + + + + + + Web.config + + + Web.config + + + + + + + + + + + + + + + + + + {a523c0e3-097d-4869-977f-15a717ea3e83} + Hangfire.Raven + + + {c02bb718-2ae4-434c-8668-c894ff663fce} + ConsoleSample + + + + + + + + + + + + + True + True + 0 + / + http://localhost:56909/ + False + False + + + False + + + + + + + + This project references NuGet package(s) that are missing on this computer. Use NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + \ No newline at end of file diff --git a/samples/MvcSample/MvcSample.csproj.DotSettings b/samples/MvcSample/MvcSample.csproj.DotSettings new file mode 100644 index 0000000..07d5ce7 --- /dev/null +++ b/samples/MvcSample/MvcSample.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/samples/MvcSample/Properties/AssemblyInfo.cs b/samples/MvcSample/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..ea6b465 --- /dev/null +++ b/samples/MvcSample/Properties/AssemblyInfo.cs @@ -0,0 +1,34 @@ +using System.Reflection; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Hangfire.MvcSample")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Hangfire.MvcSample")] +[assembly: AssemblyCopyright("Copyright © 2013")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("1579df48-9733-47a5-93b6-8b459e9912fc")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Revision and Build Numbers +// by using the '*' as shown below: +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/samples/MvcSample/Startup.cs b/samples/MvcSample/Startup.cs new file mode 100644 index 0000000..effc968 --- /dev/null +++ b/samples/MvcSample/Startup.cs @@ -0,0 +1,26 @@ +using Hangfire; +using Hangfire.Dashboard; +using Hangfire.SqlServer; +using Microsoft.Owin; +using MvcSample; +using Owin; + +[assembly: OwinStartup(typeof(Startup))] + +namespace MvcSample +{ + public class Startup + { + public void Configuration(IAppBuilder app) + { + GlobalConfiguration.Configuration + .UseSqlServerStorage(@"Server=.\sqlexpress;Database=Hangfire.Sample;Trusted_Connection=True;") + .UseMsmqQueues(@".\Private$\hangfire{0}", "default", "critical") + .UseDashboardMetric(SqlServerStorage.ActiveConnections) + .UseDashboardMetric(SqlServerStorage.TotalConnections) + .UseDashboardMetric(DashboardMetrics.FailedCount); + + app.UseHangfireDashboard(); + } + } +} diff --git a/samples/MvcSample/Views/Shared/Error.cshtml b/samples/MvcSample/Views/Shared/Error.cshtml new file mode 100644 index 0000000..b4a1239 --- /dev/null +++ b/samples/MvcSample/Views/Shared/Error.cshtml @@ -0,0 +1,17 @@ +@{ + Layout = null; +} + + + + + + Error + + +
+

Error.

+

An error occurred while processing your request.

+
+ + \ No newline at end of file diff --git a/samples/MvcSample/Views/Shared/_Layout.cshtml b/samples/MvcSample/Views/Shared/_Layout.cshtml new file mode 100644 index 0000000..cdd3281 --- /dev/null +++ b/samples/MvcSample/Views/Shared/_Layout.cshtml @@ -0,0 +1,16 @@ + + + + + + @ViewBag.Title + @Styles.Render("~/Content/css") + @Scripts.Render("~/bundles/modernizr") + + + @RenderBody() + + @Scripts.Render("~/bundles/jquery") + @RenderSection("scripts", required: false) + + diff --git a/samples/MvcSample/Views/Web.config b/samples/MvcSample/Views/Web.config new file mode 100644 index 0000000..826ce19 --- /dev/null +++ b/samples/MvcSample/Views/Web.config @@ -0,0 +1,58 @@ + + + + + +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/samples/MvcSample/Views/_ViewStart.cshtml b/samples/MvcSample/Views/_ViewStart.cshtml new file mode 100644 index 0000000..efda124 --- /dev/null +++ b/samples/MvcSample/Views/_ViewStart.cshtml @@ -0,0 +1,3 @@ +@{ + Layout = "~/Views/Shared/_Layout.cshtml"; +} \ No newline at end of file diff --git a/samples/MvcSample/Web.Debug.config b/samples/MvcSample/Web.Debug.config new file mode 100644 index 0000000..3e2a97c --- /dev/null +++ b/samples/MvcSample/Web.Debug.config @@ -0,0 +1,30 @@ + + + + + + + + + + \ No newline at end of file diff --git a/samples/MvcSample/Web.Release.config b/samples/MvcSample/Web.Release.config new file mode 100644 index 0000000..9fd481f --- /dev/null +++ b/samples/MvcSample/Web.Release.config @@ -0,0 +1,31 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/samples/MvcSample/Web.config b/samples/MvcSample/Web.config new file mode 100644 index 0000000..203dc2a --- /dev/null +++ b/samples/MvcSample/Web.config @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/samples/MvcSample/packages.config b/samples/MvcSample/packages.config new file mode 100644 index 0000000..72f6ff2 --- /dev/null +++ b/samples/MvcSample/packages.config @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Hangfire.SqlServer/DistributedLocks/RavenDistributedLock.cs b/src/Hangfire.SqlServer/DistributedLocks/RavenDistributedLock.cs new file mode 100644 index 0000000..e1b7b84 --- /dev/null +++ b/src/Hangfire.SqlServer/DistributedLocks/RavenDistributedLock.cs @@ -0,0 +1,208 @@ +// This file is part of Hangfire. +// Copyright � 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System; +using System.Linq; +using System.Collections.Generic; +using System.Data; +using System.Threading; +using Hangfire.Annotations; +using HangFire.Raven; +using Hangfire.Raven.Entities; +using Hangfire.Raven.Storage; + +namespace Hangfire.Raven.DistributedLock +{ + public class RavenDistributedLock : IDisposable + { + private const string LockMode = "Exclusive"; + private const string LockOwner = "Session"; + private const int CommandTimeoutAdditionSeconds = 1; + + private static readonly IDictionary LockErrorMessages = new Dictionary + { + { -1, "The lock request timed out" }, + { -2, "The lock request was canceled" }, + { -3, "The lock request was chosen as a deadlock victim" }, + { -999, "Indicates a parameter validation or other call error" } + }; + + private static readonly ThreadLocal> AcquiredLocks = new ThreadLocal>(() => new Dictionary()); + + private static RavenStorage _storage; + private static string _resource; + private static RavenStorageOptions _options; + + private static Timer _heartbeatTimer = null; + + private bool _completed; + + public RavenDistributedLock([NotNull] RavenStorage storage, [NotNull] string resource, TimeSpan timeout, RavenStorageOptions options) + { + storage.ThrowIfNull("storage"); + resource.ThrowIfNull("resource"); + + if ((timeout.TotalSeconds + CommandTimeoutAdditionSeconds) > int.MaxValue) { + throw new ArgumentException(string.Format("The timeout specified is too large. Please supply a timeout equal to or less than {0} seconds", + int.MaxValue - CommandTimeoutAdditionSeconds), + "timeout"); + } + + _storage = storage; + _resource = resource; + _options = options; + + if (!AcquiredLocks.Value.ContainsKey(_resource)) { + Acquire(_resource, timeout); + AcquiredLocks.Value[_resource] = 1; + } else { + AcquiredLocks.Value[_resource]++; + } + } + + public void Dispose() + { + if (_completed) + return; + + _completed = true; + + if (!AcquiredLocks.Value.ContainsKey(_resource)) + return; + + AcquiredLocks.Value[_resource]--; + + if (AcquiredLocks.Value[_resource] != 0) + return; + + Release(_resource); + AcquiredLocks.Value.Remove(_resource); + } + + internal static void Acquire(string resource, TimeSpan timeout) + { + try { + RemoveDeadLocks(); + + // Check lock + DateTime lockTimeoutTime = DateTime.Now.Add(timeout); + bool isLockedBySomeoneElse; + bool isFirstAttempt = true; + do { + using (var repository = new Repository()) { + isLockedBySomeoneElse = repository.Session.Query() + .FirstOrDefault(t => t.Resource == resource && t.ClientId == _options.ClientId) != null; + } + + if (isFirstAttempt == true) { + isFirstAttempt = false; + } else { + Thread.Sleep((int)timeout.TotalMilliseconds / 10); + } + } + while ((isLockedBySomeoneElse == true) && (lockTimeoutTime >= DateTime.Now)); + + // Set lock + if (isLockedBySomeoneElse == false) { + using (var repository = new Repository()) { + var distributedLocks = repository.Session.Query().Where(t => t.Resource == resource).ToList(); + + if (!distributedLocks.Any()) { + distributedLocks.Add(new DistributedLocks + { + Resource = resource, + }); + } + + foreach (var distributedLock in distributedLocks) { + distributedLock.ClientId = _options.ClientId; + distributedLock.LockCount = 1; + distributedLock.Heartbeat = DateTime.UtcNow; + + repository.Save(distributedLock); + } + } + + StartHeartBeat(); + } else { + throw new RavenDistributedLockException(string.Format("Could not place a lock on the resource '{0}': {1}.", _resource, "The lock request timed out")); + } + } catch (Exception ex) { + if (ex is RavenDistributedLockException) { + throw; + } else { + throw new RavenDistributedLockException(string.Format("Could not place a lock on the resource '{0}': {1}.", _resource, "Check inner exception for details"), ex); + } + } + } + + internal static void Release(string resource) + { + try { + RemoveDeadLocks(); + + // Remove resource lock + using (var repository = new Repository()) { + var distributedLocks = repository.Session.Query().Where(t => t.Resource == _resource && t.ClientId == _options.ClientId).ToList(); + + foreach (var distributedLock in distributedLocks) { + repository.Delete(distributedLock); + } + } + + if (_heartbeatTimer != null) { + _heartbeatTimer.Dispose(); + _heartbeatTimer = null; + } + } catch (Exception ex) { + throw new RavenDistributedLockException(string.Format("Could not release a lock on the resource '{0}': {1}.", _resource, "Check inner exception for details"), ex); + } + } + + private static void StartHeartBeat() + { + TimeSpan timerInterval = TimeSpan.FromMilliseconds(_options.DistributedLockLifetime.TotalMilliseconds / 5); + + _heartbeatTimer = new Timer(state => + { + try { + using (var repository = new Repository()) { + var distributedLocks = repository.Session.Query().Where(t => t.Resource == _resource && t.ClientId == _options.ClientId).ToList(); + + foreach (var distributedLock in distributedLocks) { + distributedLock.Heartbeat = DateTime.UtcNow; + repository.Save(distributedLock); + } + } + } catch (Exception ex) { + Console.WriteLine("Unable to update heartbeat on the resource '{0}'", ex, _resource); + } + }, null, timerInterval, timerInterval); + } + + private static void RemoveDeadLocks() + { + using (var repository = new Repository()) { + var heartBeat = DateTime.UtcNow.Subtract(_options.DistributedLockLifetime); + var deadLocks = repository.Session.Query().Where(t => t.Resource == _resource && t.Heartbeat == heartBeat).ToList(); + + foreach (var deadlock in deadLocks) { + repository.Delete(deadlock); + } + } + } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/DistributedLocks/RavenDistributedLockException.cs b/src/Hangfire.SqlServer/DistributedLocks/RavenDistributedLockException.cs new file mode 100644 index 0000000..9771862 --- /dev/null +++ b/src/Hangfire.SqlServer/DistributedLocks/RavenDistributedLockException.cs @@ -0,0 +1,30 @@ +using System; + +namespace Hangfire.Raven.DistributedLock +{ + /// + /// Represents exceptions for distributed lock implementation for MongoDB + /// + [Serializable] + public class RavenDistributedLockException : Exception + { + /// + /// Creates exception + /// + /// Exception message + public RavenDistributedLockException(string message) + : base(message) + { + } + + /// + /// Creates exception with inner exception + /// + /// Exception message + /// Inner exception + public RavenDistributedLockException(string message, Exception innerException) + : base(message, innerException) + { + } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/Entities/AggregatedCounter.cs b/src/Hangfire.SqlServer/Entities/AggregatedCounter.cs new file mode 100644 index 0000000..af03256 --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/AggregatedCounter.cs @@ -0,0 +1,15 @@ +using System; + +namespace Hangfire.Raven.Entities +{ + public class AggregatedCounter + { + public string Id { get; set; } + + public string Key { get; set; } + + public long Value { get; set; } + + public DateTime? ExpireAt { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/Counter.cs b/src/Hangfire.SqlServer/Entities/Counter.cs new file mode 100644 index 0000000..8f18b88 --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/Counter.cs @@ -0,0 +1,18 @@ +using Hangfire.Raven.Entities.Identity; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Hangfire.Raven.Entities +{ + public class Counter : BaseEntity + { + public string Key { get; set; } + + public int Value { get; set; } + + public DateTime? ExpireAt { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/DistributedLocks.cs b/src/Hangfire.SqlServer/Entities/DistributedLocks.cs new file mode 100644 index 0000000..511a325 --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/DistributedLocks.cs @@ -0,0 +1,16 @@ +using Hangfire.Raven.Entities.Identity; +using System; + +namespace Hangfire.Raven.Entities +{ + public class DistributedLocks : BaseEntity + { + public string Resource { get; set; } + + public string ClientId { get; set; } + + public int LockCount { get; set; } + + public DateTime Heartbeat { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/EnqueuedAndFetchedCount.cs b/src/Hangfire.SqlServer/Entities/EnqueuedAndFetchedCount.cs new file mode 100644 index 0000000..8f76d57 --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/EnqueuedAndFetchedCount.cs @@ -0,0 +1,9 @@ +namespace Hangfire.Raven.Entities +{ + public class EnqueuedAndFetchedCount + { + public int? EnqueuedCount { get; set; } + + public int? FetchedCount { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/Identity/BaseEntity.cs b/src/Hangfire.SqlServer/Entities/Identity/BaseEntity.cs new file mode 100644 index 0000000..09173df --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/Identity/BaseEntity.cs @@ -0,0 +1,7 @@ +namespace Hangfire.Raven.Entities.Identity +{ + public class BaseEntity + { + public string Id { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/JobParameter.cs b/src/Hangfire.SqlServer/Entities/JobParameter.cs new file mode 100644 index 0000000..608ac0f --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/JobParameter.cs @@ -0,0 +1,27 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using Hangfire.Raven.Entities.Identity; + +namespace Hangfire.Raven.Entities +{ + internal class JobParameter : BaseEntity + { + public string JobId { get; set; } + public string Name { get; set; } + public string Value { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/JobQueue.cs b/src/Hangfire.SqlServer/Entities/JobQueue.cs new file mode 100644 index 0000000..e849048 --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/JobQueue.cs @@ -0,0 +1,12 @@ +using Hangfire.Raven.Entities.Identity; +using System; + +namespace Hangfire.Raven.Entities +{ + public class JobQueue : BaseEntity + { + public string JobId { get; set; } + public string Queue { get; set; } + public DateTime? FetchedAt { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/RavenFetchedJob.cs b/src/Hangfire.SqlServer/Entities/RavenFetchedJob.cs new file mode 100644 index 0000000..6bbd59b --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/RavenFetchedJob.cs @@ -0,0 +1,88 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using Hangfire.Annotations; +using Hangfire.Storage; +using HangFire.Raven; +using Hangfire.Raven.Storage; + +namespace Hangfire.Raven.Entities +{ + public class RavenFetchedJob : IFetchedJob + { + private readonly RavenStorage _storage; + private bool _requeued { get; set; } + private bool _removedFromQueue { get; set; } + private bool _disposed { get; set; } + + public string Id { get; set; } + public string JobId { get; set; } + public string Queue { get; set; } + + public RavenFetchedJob( + [NotNull] RavenStorage storage, + JobQueue jobQueue) + { + storage.ThrowIfNull("storage"); + jobQueue.ThrowIfNull("jobQueue"); + + _storage = storage; + + JobId = jobQueue.JobId; + Queue = jobQueue.Queue; + Id = jobQueue.Id; + } + + public void RemoveFromQueue() + { + using (var repository = new Repository()) { + var job = repository.Session.Load(Id); + + if (job != null) { + repository.Delete(job); + } + } + + _removedFromQueue = true; + } + + public void Requeue() + { + using (var repository = new Repository()) { + var job = repository.Session.Load(Id); + + job.FetchedAt = null; + + repository.Save(job); + } + + _requeued = true; + } + + public void Dispose() + { + if (_disposed) { + return; + } + + if (!_removedFromQueue && !_requeued) { + Requeue(); + } + + _disposed = true; + } + } +} diff --git a/src/Hangfire.SqlServer/Entities/RavenHash.cs b/src/Hangfire.SqlServer/Entities/RavenHash.cs new file mode 100644 index 0000000..ce01c7b --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/RavenHash.cs @@ -0,0 +1,31 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using Hangfire.Raven.Entities.Identity; +using System; + +namespace Hangfire.Raven.Entities +{ + public class RavenHash : BaseEntity + { + public string Key { get; set; } + public string Field { get; set; } + public string Value { get; set; } + public DateTime? ExpireAt { get; set; } + + public string JobId { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/RavenJob.cs b/src/Hangfire.SqlServer/Entities/RavenJob.cs new file mode 100644 index 0000000..d8a174d --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/RavenJob.cs @@ -0,0 +1,34 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using Hangfire.Raven.Entities.Identity; +using System; + +namespace Hangfire.Raven.Entities +{ + internal class RavenJob : BaseEntity + { + public string InvocationData { get; set; } + public string Arguments { get; set; } + public DateTime CreatedAt { get; set; } + public DateTime? ExpireAt { get; set; } + + public string StateId { get; set; } + public string StateName { get; set; } + public string StateReason { get; set; } + public string StateData { get; set; } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/Entities/RavenList.cs b/src/Hangfire.SqlServer/Entities/RavenList.cs new file mode 100644 index 0000000..cefef85 --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/RavenList.cs @@ -0,0 +1,14 @@ +using Hangfire.Raven.Entities.Identity; +using System; + +namespace Hangfire.Raven.Entities +{ + public class RavenList : BaseEntity + { + public string Key { get; set; } + + public string Value { get; set; } + + public DateTime ExpireAt { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/RavenServer.cs b/src/Hangfire.SqlServer/Entities/RavenServer.cs new file mode 100644 index 0000000..b855f57 --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/RavenServer.cs @@ -0,0 +1,27 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using Hangfire.Raven.Entities.Identity; +using System; + +namespace Hangfire.Raven.Entities +{ + public class RavenServer : BaseEntity + { + public string Data { get; set; } + public DateTime LastHeartbeat { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/RavenSet.cs b/src/Hangfire.SqlServer/Entities/RavenSet.cs new file mode 100644 index 0000000..a04d039 --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/RavenSet.cs @@ -0,0 +1,16 @@ +using Hangfire.Raven.Entities.Identity; +using System; + +namespace Hangfire.Raven.Entities +{ + public class RavenSet : BaseEntity + { + public string Key { get; set; } + + public double Score { get; set; } + + public string Value { get; set; } + + public DateTime? ExpireAt { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/ServerData.cs b/src/Hangfire.SqlServer/Entities/ServerData.cs new file mode 100644 index 0000000..b58338c --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/ServerData.cs @@ -0,0 +1,27 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System; + +namespace Hangfire.Raven.Entities +{ + public class ServerData + { + public int WorkerCount { get; set; } + public string[] Queues { get; set; } + public DateTime? StartedAt { get; set; } + } +} diff --git a/src/Hangfire.SqlServer/Entities/State.cs b/src/Hangfire.SqlServer/Entities/State.cs new file mode 100644 index 0000000..45ef3bc --- /dev/null +++ b/src/Hangfire.SqlServer/Entities/State.cs @@ -0,0 +1,30 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using Hangfire.Raven.Entities.Identity; +using System; + +namespace Hangfire.Raven.Entities +{ + public class State : BaseEntity + { + public string JobId { get; set; } + public string Name { get; set; } + public string Reason { get; set; } + public DateTime CreatedAt { get; set; } + public string Data { get; set; } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/ExpirationManager.cs b/src/Hangfire.SqlServer/ExpirationManager.cs new file mode 100644 index 0000000..ba8775f --- /dev/null +++ b/src/Hangfire.SqlServer/ExpirationManager.cs @@ -0,0 +1,74 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System; +using System.Threading; +using Hangfire.Logging; +using Hangfire.Server; +using System.Linq; +using HangFire.Raven; +using Hangfire.Raven.Entities; + +namespace Hangfire.Raven +{ + public class ExpirationManager : IServerComponent + { + private static readonly ILog Logger = LogProvider.GetCurrentClassLogger(); + + private readonly TimeSpan _checkInterval; + + public ExpirationManager() + : this(TimeSpan.FromHours(1)) + { + } + + public ExpirationManager(TimeSpan checkInterval) + { + _checkInterval = checkInterval; + } + + public void Execute(CancellationToken cancellationToken) + { + using (var repository = new Repository()) { + var session = repository.Session; + + var now = DateTime.UtcNow; + + var counters = session.Query().Where(t => t.ExpireAt <= now).ToList(); + counters.ForEach(t => session.Delete(t)); + + var jobs = session.Query().Where(t => t.ExpireAt <= now).ToList(); + jobs.ForEach(t => session.Delete(t)); + + var hashes = session.Query().Where(t => t.ExpireAt <= now).ToList(); + hashes.ForEach(t => session.Delete(t)); + + var lists = session.Query().Where(t => t.ExpireAt <= now).ToList(); + lists.ForEach(t => session.Delete(t)); + + var sets = session.Query().Where(t => t.ExpireAt <= now).ToList(); + sets.ForEach(t => session.Delete(t)); + } + + cancellationToken.WaitHandle.WaitOne(_checkInterval); + } + + public override string ToString() + { + return "Raven Expiration Manager"; + } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/Extensions/DatabaseExtensions.cs b/src/Hangfire.SqlServer/Extensions/DatabaseExtensions.cs new file mode 100644 index 0000000..f6339f8 --- /dev/null +++ b/src/Hangfire.SqlServer/Extensions/DatabaseExtensions.cs @@ -0,0 +1,14 @@ +using Raven.Client; + +namespace Hangfire.Raven.Extensions +{ + public static class DatabaseExtensions + { + public static bool DatabaseExists(this IDocumentStore documentStore, string database) + { + var result = documentStore.DatabaseCommands.Head("Raven/Databases/" + database); + + return (result != null); + } + } +} diff --git a/src/Hangfire.SqlServer/HangFire.SqlServer.csproj.DotSettings b/src/Hangfire.SqlServer/HangFire.SqlServer.csproj.DotSettings new file mode 100644 index 0000000..5af10f9 --- /dev/null +++ b/src/Hangfire.SqlServer/HangFire.SqlServer.csproj.DotSettings @@ -0,0 +1,2 @@ + + False \ No newline at end of file diff --git a/src/Hangfire.SqlServer/Hangfire.Raven.csproj b/src/Hangfire.SqlServer/Hangfire.Raven.csproj new file mode 100644 index 0000000..8077124 --- /dev/null +++ b/src/Hangfire.SqlServer/Hangfire.Raven.csproj @@ -0,0 +1,150 @@ + + + + + Debug + AnyCPU + {A523C0E3-097D-4869-977F-15A717EA3E83} + Library + Properties + Hangfire.Raven + Hangfire.Raven + v4.5 + 512 + ..\ + + 5f064ca7 + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + bin\Debug\Hangfire.Raven.xml + false + 1591 + AnyCPU + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + bin\Release\Hangfire.Raven.xml + false + 1591 + + + + ..\..\packages\Dewey.Net.2.7.8.0\lib\net45\Dewey.Net.dll + True + + + ..\..\packages\Hangfire.Core.1.5.0\lib\net45\Hangfire.Core.dll + + + ..\..\packages\Hangfire.SqlServer.1.5.0\lib\net45\Hangfire.SqlServer.dll + + + ..\..\packages\Microsoft.Owin.3.0.0\lib\net45\Microsoft.Owin.dll + + + ..\..\packages\Microsoft.Owin.Host.SystemWeb.3.0.0\lib\net45\Microsoft.Owin.Host.SystemWeb.dll + + + ..\..\packages\Newtonsoft.Json.7.0.1\lib\net45\Newtonsoft.Json.dll + True + + + ..\..\packages\Owin.1.0\lib\net40\Owin.dll + + + ..\..\packages\RavenDB.Database.3.0.3800\lib\net45\Raven.Abstractions.dll + + + ..\..\packages\RavenDB.Client.3.0.3800\lib\net45\Raven.Client.Lightweight.dll + + + ..\..\packages\RavenDB.Database.3.0.3800\lib\net45\Raven.Database.dll + + + + + + + + + + + + + + + + Properties\SharedAssemblyInfo.cs + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Always + + + + + \ No newline at end of file diff --git a/src/Hangfire.SqlServer/JobQueues/IPersistentJobQueue.cs b/src/Hangfire.SqlServer/JobQueues/IPersistentJobQueue.cs new file mode 100644 index 0000000..6c8b3ca --- /dev/null +++ b/src/Hangfire.SqlServer/JobQueues/IPersistentJobQueue.cs @@ -0,0 +1,27 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System.Threading; +using Hangfire.Storage; + +namespace Hangfire.Raven.JobQueues +{ + public interface IPersistentJobQueue + { + IFetchedJob Dequeue(string[] queues, CancellationToken cancellationToken); + void Enqueue(string queue, string jobId); + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/JobQueues/IPersistentJobQueueMonitoringApi.cs b/src/Hangfire.SqlServer/JobQueues/IPersistentJobQueueMonitoringApi.cs new file mode 100644 index 0000000..2e3cb02 --- /dev/null +++ b/src/Hangfire.SqlServer/JobQueues/IPersistentJobQueueMonitoringApi.cs @@ -0,0 +1,32 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System.Collections.Generic; +using Hangfire.Raven.Entities; + +namespace Hangfire.Raven.JobQueues +{ + public interface IPersistentJobQueueMonitoringApi + { + IEnumerable GetQueues(); + + IEnumerable GetEnqueuedJobIds(string queue, int from, int perPage); + + IEnumerable GetFetchedJobIds(string queue, int from, int perPage); + + EnqueuedAndFetchedCount GetEnqueuedAndFetchedCount(string queue); + } +} diff --git a/src/Hangfire.SqlServer/JobQueues/IPersistentJobQueueProvider.cs b/src/Hangfire.SqlServer/JobQueues/IPersistentJobQueueProvider.cs new file mode 100644 index 0000000..9eaddab --- /dev/null +++ b/src/Hangfire.SqlServer/JobQueues/IPersistentJobQueueProvider.cs @@ -0,0 +1,24 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +namespace Hangfire.Raven.JobQueues +{ + public interface IPersistentJobQueueProvider + { + IPersistentJobQueue GetJobQueue(); + IPersistentJobQueueMonitoringApi GetJobQueueMonitoringApi(); + } +} diff --git a/src/Hangfire.SqlServer/JobQueues/PersistentJobQueueProviderCollection.cs b/src/Hangfire.SqlServer/JobQueues/PersistentJobQueueProviderCollection.cs new file mode 100644 index 0000000..39e1eba --- /dev/null +++ b/src/Hangfire.SqlServer/JobQueues/PersistentJobQueueProviderCollection.cs @@ -0,0 +1,71 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Hangfire.Raven.JobQueues +{ + public class PersistentJobQueueProviderCollection : IEnumerable + { + private readonly List _providers + = new List(); + private readonly Dictionary _providersByQueue + = new Dictionary(StringComparer.OrdinalIgnoreCase); + + private readonly IPersistentJobQueueProvider _defaultProvider; + + public PersistentJobQueueProviderCollection(IPersistentJobQueueProvider defaultProvider) + { + defaultProvider.ThrowIfNull("defaultProvider"); + + _defaultProvider = defaultProvider; + + _providers.Add(_defaultProvider); + } + + public void Add(IPersistentJobQueueProvider provider, IEnumerable queues) + { + provider.ThrowIfNull("provider"); + queues.ThrowIfNull("queues"); + + _providers.Add(provider); + + foreach (var queue in queues) + { + _providersByQueue.Add(queue, provider); + } + } + + public IPersistentJobQueueProvider GetProvider(string queue) + { + return _providersByQueue.ContainsKey(queue) + ? _providersByQueue[queue] + : _defaultProvider; + } + + public IEnumerator GetEnumerator() + { + return _providers.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + return GetEnumerator(); + } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/JobQueues/RavenJobQueue.cs b/src/Hangfire.SqlServer/JobQueues/RavenJobQueue.cs new file mode 100644 index 0000000..6646e83 --- /dev/null +++ b/src/Hangfire.SqlServer/JobQueues/RavenJobQueue.cs @@ -0,0 +1,102 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System; +using System.Threading; +using Hangfire.Annotations; +using Hangfire.Storage; +using HangFire.Raven; +using Raven.Client.Linq; +using System.Linq; +using Hangfire.Raven.Entities; +using Hangfire.Raven.Storage; + +namespace Hangfire.Raven.JobQueues +{ + public class RavenJobQueue : IPersistentJobQueue + { + private readonly RavenStorage _storage; + private readonly RavenStorageOptions _options; + + private static object _lockMethod = new object(); + + public RavenJobQueue([NotNull] RavenStorage storage, RavenStorageOptions options) + { + storage.ThrowIfNull("storage"); + options.ThrowIfNull("options"); + + _storage = storage; + _options = options; + } + + [NotNull] + public IFetchedJob Dequeue(string[] queues, CancellationToken cancellationToken) + { + queues.ThrowIfNull("queues"); + + if (queues.Length == 0) { + throw new ArgumentException("Queue array must be non-empty.", "queues"); + } + + lock (_lockMethod) { + + JobQueue fetchedJob; + + cancellationToken.ThrowIfCancellationRequested(); + + do { + using (var repository = new Repository()) { + + fetchedJob = repository.Session.Query().FirstOrDefault(t => t.FetchedAt == null + && t.Queue.In(queues)); + + if (fetchedJob == null) { + fetchedJob = repository.Session.Query().FirstOrDefault(t => + t.FetchedAt < DateTime.UtcNow.AddSeconds(_options.InvisibilityTimeout.Negate().TotalSeconds) + && t.Queue.In(queues)); + + if (fetchedJob == null) { + cancellationToken.WaitHandle.WaitOne(_options.QueuePollInterval); + cancellationToken.ThrowIfCancellationRequested(); + + continue; + } + } + + fetchedJob.FetchedAt = DateTime.UtcNow; + + repository.Save(fetchedJob); + } + } while (fetchedJob == null); + + return new RavenFetchedJob(_storage, fetchedJob); + } + } + + public void Enqueue(string queue, string jobId) + { + using (var repository = new Repository()) { + var jobQueue = new JobQueue + { + JobId = jobId, + Queue = queue + }; + + repository.Save(jobQueue); + } + } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/JobQueues/RavenJobQueueMonitoringApi.cs b/src/Hangfire.SqlServer/JobQueues/RavenJobQueueMonitoringApi.cs new file mode 100644 index 0000000..a4ba004 --- /dev/null +++ b/src/Hangfire.SqlServer/JobQueues/RavenJobQueueMonitoringApi.cs @@ -0,0 +1,103 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System.Collections.Generic; +using System.Linq; +using Hangfire.Raven.Entities; +using HangFire.Raven; +using Hangfire.Raven.JobQueues; + +namespace Hangfire.Raven.JobQueues +{ + internal class RavenJobQueueMonitoringApi : IPersistentJobQueueMonitoringApi + { + public IEnumerable GetQueues() + { + using (var repository = new Repository()) { + return repository.Session.Query() + .Select(x => x.Queue) + .Distinct() + .ToList(); + } + } + + public IEnumerable GetEnqueuedJobIds(string queue, int @from, int perPage) + { + int start = @from + 1; + int end = from + perPage; + + using (var repository = new Repository()) { + var jobs = repository.Session.Query().Where(t => t.Queue == queue && t.FetchedAt == null) + .Select((data, i) => new { Index = i + 1, Data = data }) + .Where(_ => (_.Index >= start) && (_.Index <= end)) + .Select(x => x.Data) + .ToList(); + + var results = new List(); + + foreach (var item in jobs) { + var job = repository.Session.Query().FirstOrDefault(t => t.Id == item.JobId); + + if (job != null && repository.Session.Query().FirstOrDefault(t => t.Id == job.StateId) != null) { + results.Add(job); + } + } + + return results.Select(t => t.Id).ToList(); + } + } + + public IEnumerable GetFetchedJobIds(string queue, int @from, int perPage) + { + int start = @from + 1; + int end = from + perPage; + + using (var repository = new Repository()) { + var jobs = repository.Session.Query().Where(t => t.Queue == queue && t.FetchedAt != null) + .Select((data, i) => new { Index = i + 1, Data = data }) + .Where(_ => (_.Index >= start) && (_.Index <= end)) + .Select(x => x.Data); + + var results = new List(); + + foreach (var item in jobs) { + var job = repository.Session.Query().FirstOrDefault(t => t.Id == item.JobId); + + if (job != null) { + results.Add(job.Id); + } + } + + return results; + } + } + + public EnqueuedAndFetchedCount GetEnqueuedAndFetchedCount(string queue) + { + using (var repository = new Repository()) { + int enqueuedCount = repository.Session.Query().Where(t => t.Queue == queue && t.FetchedAt == null).Count(); + + int fetchedCount = repository.Session.Query().Where(t => t.Queue == queue && t.FetchedAt != null).Count(); + + return new EnqueuedAndFetchedCount + { + EnqueuedCount = enqueuedCount, + FetchedCount = fetchedCount + }; + } + } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/JobQueues/RavenJobQueueProvider.cs b/src/Hangfire.SqlServer/JobQueues/RavenJobQueueProvider.cs new file mode 100644 index 0000000..55773d6 --- /dev/null +++ b/src/Hangfire.SqlServer/JobQueues/RavenJobQueueProvider.cs @@ -0,0 +1,47 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System; +using Hangfire.Annotations; +using Hangfire.Raven.Storage; + +namespace Hangfire.Raven.JobQueues +{ + public class RavenJobQueueProvider : IPersistentJobQueueProvider + { + private readonly IPersistentJobQueue _jobQueue; + private readonly IPersistentJobQueueMonitoringApi _monitoringApi; + + public RavenJobQueueProvider([NotNull] RavenStorage storage, [NotNull] RavenStorageOptions options) + { + storage.ThrowIfNull("storage"); + options.ThrowIfNull("options"); + + _jobQueue = new RavenJobQueue(storage, options); + //_monitoringApi = new RavenJobQueueMonitoringApi(storage); + } + + public IPersistentJobQueue GetJobQueue() + { + return _jobQueue; + } + + public IPersistentJobQueueMonitoringApi GetJobQueueMonitoringApi() + { + return _monitoringApi; + } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/Listeners/TakeNewestConflictResolutionListener.cs b/src/Hangfire.SqlServer/Listeners/TakeNewestConflictResolutionListener.cs new file mode 100644 index 0000000..67845b3 --- /dev/null +++ b/src/Hangfire.SqlServer/Listeners/TakeNewestConflictResolutionListener.cs @@ -0,0 +1,24 @@ +using Raven.Abstractions.Data; +using Raven.Client.Listeners; +using System.Linq; + +namespace Hangfire.Raven.Listeners +{ + public class TakeNewestConflictResolutionListener : IDocumentConflictListener + { + public bool TryResolveConflict(string key, JsonDocument[] conflictedDocs, out JsonDocument resolvedDocument) + { + var maxDate = conflictedDocs.Max(x => x.LastModified); + resolvedDocument = conflictedDocs + .FirstOrDefault(x => x.LastModified == maxDate); + + if (resolvedDocument == null) + return false; + + resolvedDocument.Metadata.Remove("@id"); + resolvedDocument.Metadata.Remove("@etag"); + + return true; + } + } +} diff --git a/src/Hangfire.SqlServer/ObjectExtensions.cs b/src/Hangfire.SqlServer/ObjectExtensions.cs new file mode 100644 index 0000000..97c216b --- /dev/null +++ b/src/Hangfire.SqlServer/ObjectExtensions.cs @@ -0,0 +1,14 @@ +using System; + +namespace Hangfire.Raven +{ + public static class ObjectExtensions + { + public static void ThrowIfNull(this object value, string name) + { + if (value == null) { + throw new ArgumentNullException(name); + } + } + } +} diff --git a/src/Hangfire.SqlServer/Properties/AssemblyInfo.cs b/src/Hangfire.SqlServer/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..2ef0d6a --- /dev/null +++ b/src/Hangfire.SqlServer/Properties/AssemblyInfo.cs @@ -0,0 +1,11 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +[assembly: AssemblyTitle("Hangfire.Raven")] +[assembly: AssemblyDescription("Raven job storage for Hangfire")] +[assembly: Guid("3d96bf2f-8854-4872-aee3-faf81d121a4d")] + +[assembly: InternalsVisibleTo("Hangfire.Raven.Tests")] +// Allow the generation of mocks for internal types +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] diff --git a/src/Hangfire.SqlServer/Raven.Studio.Html5.zip b/src/Hangfire.SqlServer/Raven.Studio.Html5.zip new file mode 100644 index 0000000..1acf469 Binary files /dev/null and b/src/Hangfire.SqlServer/Raven.Studio.Html5.zip differ diff --git a/src/Hangfire.SqlServer/RavenConnection.cs b/src/Hangfire.SqlServer/RavenConnection.cs new file mode 100644 index 0000000..dba07c4 --- /dev/null +++ b/src/Hangfire.SqlServer/RavenConnection.cs @@ -0,0 +1,536 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Hangfire.Common; +using Hangfire.Server; +using Hangfire.Storage; +using Raven.Client; +using Hangfire.Raven.DistributedLock; +using HangFire.Raven; +using Hangfire.Raven.Entities; +using Hangfire.Raven.Storage; + +namespace Hangfire.Raven +{ + public class RavenConnection : JobStorageConnection + { + private readonly RavenStorageOptions _options; + private readonly RavenStorage _storage; + + public RavenConnection(RavenStorage ravenStorage) + { + ravenStorage.ThrowIfNull("RavenStorage"); + + _storage = ravenStorage; + _options = new RavenStorageOptions(); + } + + public RavenConnection(RavenStorage ravenStorage, RavenStorageOptions options) + : this(ravenStorage) + { + options.ThrowIfNull("options"); + + _storage = ravenStorage; + _options = options; + } + + public override IWriteOnlyTransaction CreateWriteTransaction() + { + return new RavenWriteOnlyTransaction(_storage); + } + + public override IDisposable AcquireDistributedLock(string resource, TimeSpan timeout) + { + return new RavenDistributedLock(_storage, string.Format("HangFire:{0}", resource), timeout, _options); + } + + public static IDocumentStore _documentStore; + + public override IFetchedJob FetchNextJob(string[] queues, CancellationToken cancellationToken) + { + queues.ThrowIfNull("queues"); + if (queues.Length == 0) { + throw new ArgumentNullException("queues"); + } + + var providers = queues + .Select(queue => _storage.QueueProviders.GetProvider(queue)) + .Distinct() + .ToArray(); + + if (providers.Length != 1) { + throw new InvalidOperationException(string.Format( + "Multiple provider instances registered for queues: {0}. You should choose only one type of persistent queues per server instance.", + string.Join(", ", queues))); + } + + var persistentQueue = providers[0].GetJobQueue(); + return persistentQueue.Dequeue(queues, cancellationToken); + } + + public override string CreateExpiredJob( + Job job, + IDictionary parameters, + DateTime createdAt, + TimeSpan expireIn) + { + job.ThrowIfNull("job"); + parameters.ThrowIfNull("parameters"); + + var invocationData = InvocationData.Serialize(job); + + var ravenJob = new RavenJob + { + Id = Guid.NewGuid().ToString(), + InvocationData = JobHelper.ToJson(invocationData), + Arguments = invocationData.Arguments, + CreatedAt = createdAt, + ExpireAt = createdAt.Add(expireIn) + }; + + using (var repository = new Repository()) { + repository.Save(ravenJob); + + if (parameters.Count > 0) { + foreach (var parameter in parameters) { + repository.Save(new JobParameter + { + JobId = ravenJob.Id, + Name = parameter.Key, + Value = parameter.Value + }); + } + } + + return ravenJob.Id.ToString(); + } + } + + public override JobData GetJobData(string id) + { + id.ThrowIfNull("id"); + + using (var repository = new Repository()) { + var jobData = repository.Session.Load(id); + + if (jobData == null) { + return null; + } + + var invocationData = JobHelper.FromJson(jobData.InvocationData); + invocationData.Arguments = jobData.Arguments; + + Job job = null; + JobLoadException loadException = null; + + try { + job = invocationData.Deserialize(); + } catch (JobLoadException ex) { + loadException = ex; + } + + return new JobData + { + Job = job, + State = jobData.StateName, + CreatedAt = jobData.CreatedAt, + LoadException = loadException + }; + } + } + + public override StateData GetStateData(string jobId) + { + jobId.ThrowIfNull("jobId"); + + using (var repository = new Repository()) { + RavenJob job = repository.Session.Load(jobId); + if (job == null) { + return null; + } + + State state = repository.Session.Query().FirstOrDefault(t => t.Id == job.StateId); + if (state == null) { + return null; + } + + return new StateData + { + Name = state.Name, + Reason = state.Reason, + Data = JobHelper.FromJson>(state.Data) + }; + } + } + + public override void SetJobParameter(string id, string name, string value) + { + id.ThrowIfNull("id"); + name.ThrowIfNull("name"); + + using (var repository = new Repository()) { + var results = repository.Session.Query().Where(t => t.JobId == id && t.Name == name).ToList(); + + results.ForEach(t => { + t.Value = value; + repository.Save(t); + }); + } + } + + public override string GetJobParameter(string id, string name) + { + id.ThrowIfNull("id"); + name.ThrowIfNull("name"); + + using (var repository = new Repository()) { + var jobParameter = repository.Session.Query().FirstOrDefault(t => t.JobId == id && t.Name == name); + + return jobParameter != null ? jobParameter.Value : null; + } + } + + public override HashSet GetAllItemsFromSet(string key) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var results = repository.Session.Query().Where(t => t.Key == key).Select(t => t.Value).ToList(); + + return new HashSet(results); + } + } + + public override string GetFirstByLowestScoreFromSet(string key, double fromScore, double toScore) + { + key.ThrowIfNull("key"); + + if (toScore < fromScore) { + throw new ArgumentException("The `toScore` value must be higher or equal to the `fromScore` value."); + } + + using (var repository = new Repository()) { + var set = repository.Session.Query() + .Where(t => t.Key == key) + .Where(t => t.Score >= fromScore) + .Where(t => t.Score <= toScore) + .OrderBy(t => t.Score) + .FirstOrDefault(); + + + return set != null ? set.Value : null; + } + } + + public override void SetRangeInHash(string key, IEnumerable> keyValuePairs) + { + key.ThrowIfNull("key"); + keyValuePairs.ThrowIfNull("keyValuePairs"); + + using (var repository = new Repository()) { + foreach (var keyValuePair in keyValuePairs) { + var results = repository.Session.Query().Where(t => t.Key == key && t.Field == keyValuePair.Key).ToList(); + + results.ForEach(t => + { + t.Value = keyValuePair.Value; + repository.Save(t); + }); + } + } + } + + public override Dictionary GetAllEntriesFromHash(string key) + { + key.ThrowIfNull("key"); + + using (var session = new Repository().Session) { + var result = session.Query().Where(t => t.Key == key) + .ToDictionary(x => x.Field, x => x.Value); + + return result.Count != 0 ? result : null; + } + } + + public override void AnnounceServer(string serverId, ServerContext context) + { + serverId.ThrowIfNull("serverId"); + context.ThrowIfNull("context"); + + var data = new ServerData + { + WorkerCount = context.WorkerCount, + Queues = context.Queues, + StartedAt = DateTime.UtcNow, + }; + + using (var repository = new Repository()) { + var servers = repository.Session.Query() + .Where(t => t.Id == serverId) + .ToList(); + + var queues = servers.SelectMany(t => JobHelper.FromJson(t.Data).Queues).ToList(); + queues.AddRange(data.Queues.Select(t => t).ToList()); + data.Queues = queues.ToArray(); + + data.WorkerCount += servers.Select(t => JobHelper.FromJson(t.Data).WorkerCount).Sum(t => t); + + foreach (var item in servers) { + repository.Delete(item); + } + + var server = new RavenServer + { + Data = JobHelper.ToJson(data), + Id = serverId, + LastHeartbeat = DateTime.UtcNow + }; + + repository.Save(server); + } + } + + public override void RemoveServer(string serverId) + { + serverId.ThrowIfNull("serverId"); + + using (var repository = new Repository()) { + var servers = repository.Session.Query() + .Where(t => t.Id == serverId) + .ToList(); + + foreach (var item in servers) { + repository.Delete(item); + } + } + } + + public override void Heartbeat(string serverId) + { + serverId.ThrowIfNull("serverId"); + + using (var repository = new Repository()) { + var server = repository.Session.Load(serverId); + + if (server == null) { + server = new RavenServer + { + Id = serverId + }; + } + + server.LastHeartbeat = DateTime.UtcNow; + + repository.Save(server); + } + } + + public override int RemoveTimedOutServers(TimeSpan timeOut) + { + if (timeOut.Duration() != timeOut) { + throw new ArgumentException("The `timeOut` value must be positive.", "timeOut"); + } + + using (var repository = new Repository()) { + var heartBeatCutOff = DateTime.UtcNow.Add(timeOut.Negate()); + + var results = repository.Session.Query() + .Where(t => t.LastHeartbeat < heartBeatCutOff) + .ToList(); + + foreach (var item in results) { + repository.Delete(item); + } + + return results.Count; + } + } + + public override long GetSetCount(string key) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var count = repository.Session.Query() + .Where(t => t.Key == key) + .Count(); + + return count; + } + } + + public override List GetRangeFromSet(string key, int startingFrom, int endingAt) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var values = repository.Session.Query() + .Where(t => t.Key == key) + .Skip(startingFrom - 1) + .Take(endingAt - startingFrom + 1) + .Select(t => t.Value) + .ToList(); + + return values; + } + } + + public override TimeSpan GetSetTtl(string key) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var expireAt = repository.Session.Query() + .Where(t => t.Key == key) + .Select(t => t.ExpireAt) + .ToArray(); + + if (expireAt.Any() == false) { + return TimeSpan.FromSeconds(-1); + } + + return expireAt.Where(t => t.HasValue).Min(t => t.Value) - DateTime.UtcNow; + } + } + + public override long GetCounter(string key) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var values = repository.Session.Query() + .Where(t => t.Key == key) + .Select(t => t.Value) + .ToArray(); + + return values.Any() ? values.Sum() : 0; + } + } + + public override long GetHashCount(string key) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var count = repository.Session.Query() + .Where(t => t.Key == key) + .Count(); + + return count; + } + } + + public override TimeSpan GetHashTtl(string key) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var hashes = repository.Session.Query() + .Where(t => t.Key == key) + .ToList(); + + DateTime? result = hashes.Any() ? hashes.Min(x => x.ExpireAt) : null; + + if (!result.HasValue) { + return TimeSpan.FromSeconds(-1); + } + + return result.Value - DateTime.UtcNow; + } + } + + public override string GetValueFromHash(string key, string name) + { + key.ThrowIfNull("key"); + name.ThrowIfNull("name"); + + using (var repository = new Repository()) { + var result = repository.Session.Query() + .Where(t => t.Key == key) + .Where(t => t.Field == name) + .FirstOrDefault(); + + return result != null ? result.Value : null; + } + } + + public override long GetListCount(string key) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var count = repository.Session.Query() + .Where(t => t.Key == key) + .Count(); + + return count; + } + } + + public override TimeSpan GetListTtl(string key) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var results = repository.Session.Query() + .Where(t => t.Key == key) + .ToList(); + + DateTime? ttl = results.Any() ? results.Min(t => t.ExpireAt) : (DateTime?)null; + + if (!ttl.HasValue) { + return TimeSpan.FromSeconds(-1); + } + + return ttl.Value - DateTime.UtcNow; + } + } + + public override List GetRangeFromList(string key, int startingFrom, int endingAt) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var results = repository.Session.Query() + .Where(t => t.Key == key) + .Select(t => t.Value) + .Skip(startingFrom - 1) + .Take(endingAt - startingFrom + 1) + .ToList(); + + return results; + } + } + + public override List GetAllItemsFromList(string key) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var results = repository.Session.Query() + .Where(t => t.Key == key) + .Select(t => t.Value) + .ToList(); + + return results; + } + } + } +} diff --git a/src/Hangfire.SqlServer/RavenWriteOnlyTransaction.cs b/src/Hangfire.SqlServer/RavenWriteOnlyTransaction.cs new file mode 100644 index 0000000..2ffced0 --- /dev/null +++ b/src/Hangfire.SqlServer/RavenWriteOnlyTransaction.cs @@ -0,0 +1,373 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System; +using System.Collections.Generic; +using System.Linq; +using Hangfire.Annotations; +using Hangfire.Common; +using Hangfire.States; +using Hangfire.Storage; +using HangFire.Raven; +using Hangfire.Raven.Entities; +using Hangfire.Raven.Storage; + +namespace Hangfire.Raven +{ + internal class RavenWriteOnlyTransaction : JobStorageTransaction + { + private readonly Queue> _commandQueue + = new Queue>(); + + private readonly SortedSet _lockedResources = new SortedSet(); + private readonly RavenStorage _storage; + public RavenWriteOnlyTransaction([NotNull] RavenStorage storage) + { + storage.ThrowIfNull("storage"); + + _storage = storage; + } + + public override void Commit() + { + foreach (var command in _commandQueue) { + command(new RavenConnection(_storage)); + } + } + + public override void ExpireJob(string jobId, TimeSpan expireIn) + { + using (var repository = new Repository()) { + var result = repository.Session.Load(jobId); + + result.ExpireAt = DateTime.UtcNow.Add(expireIn); + + repository.Save(result); + } + } + + public override void PersistJob(string jobId) + { + using (var repository = new Repository()) { + var result = repository.Session.Load(jobId); + + result.ExpireAt = null; + + repository.Save(result); + } + } + + public override void SetJobState(string jobId, IState state) + { + using (var repository = new Repository()) { + var jobState = repository.Session.Query().FirstOrDefault(t => t.JobId == jobId); + + if (jobState == null) { + jobState = new State + { + CreatedAt = DateTime.UtcNow, + JobId = jobId + }; + } + + jobState.Reason = state.Reason; + jobState.Name = state.Name; + jobState.Data = JobHelper.ToJson(state.SerializeData()); + + repository.Save(jobState); + + var result = repository.Session.Load(jobId); + + result.StateId = jobState.Id; + result.StateName = state.Name; + result.StateData = JobHelper.ToJson(state.SerializeData()); + result.StateReason = state.Reason; + + repository.Save(result); + } + } + + public override void AddJobState(string jobId, IState state) + { + using (var repository = new Repository()) { + var jobState = new State + { + Id = Guid.NewGuid().ToString(), + JobId = jobId, + Name = state.Name, + Reason = state.Reason, + CreatedAt = DateTime.UtcNow, + Data = JobHelper.ToJson(state.SerializeData()) + }; + + repository.Save(jobState); + } + } + + public override void AddToQueue(string queue, string jobId) + { + var provider = _storage.QueueProviders.GetProvider(queue); + var persistentQueue = provider.GetJobQueue(); + + persistentQueue.Enqueue(queue, jobId); + } + + public override void IncrementCounter(string key) + { + using (var repository = new Repository()) { + var counter = repository.Session.Query().FirstOrDefault(t => t.Key == key); + + if (counter == null) { + counter = new Counter + { + Id = Guid.NewGuid().ToString(), + Key = key, + Value = 0 + }; + } + + counter.Value += 1; + + repository.Save(counter); + } + } + + public override void IncrementCounter(string key, TimeSpan expireIn) + { + using (var repository = new Repository()) { + var counter = repository.Session.Query().FirstOrDefault(t => t.Key == key); + + if (counter == null) { + counter = new Counter + { + Id = Guid.NewGuid().ToString(), + ExpireAt = DateTime.UtcNow.Add(expireIn), + Key = key, + Value = 0 + }; + } + + counter.Value += 1; + + repository.Save(counter); + } + } + + public override void DecrementCounter(string key) + { + using (var repository = new Repository()) { + var counter = repository.Session.Query().FirstOrDefault(t => t.Key == key); + + if (counter == null) { + counter = new Counter + { + Id = Guid.NewGuid().ToString(), + Key = key, + Value = 0 + }; + } + + counter.Value -= 1; + + repository.Save(counter); + } + } + + public override void DecrementCounter(string key, TimeSpan expireIn) + { + using (var repository = new Repository()) { + var counter = repository.Session.Query().FirstOrDefault(t => t.Key == key); + + if (counter == null) { + counter = new Counter + { + Id = Guid.NewGuid().ToString(), + ExpireAt = DateTime.UtcNow.Add(expireIn), + Key = key, + Value = 0 + }; + } + + counter.Value -= 1; + + repository.Save(counter); + } + } + + public override void AddToSet(string key, string value) + { + AddToSet(key, value, 0.0); + } + + public override void AddToSet(string key, string value, double score) + { + using (var repository = new Repository()) { + var set = repository.Session.Query().FirstOrDefault(t => t.Key == key && t.Value == value); + + if (set == null) { + set = new RavenSet + { + Id = Guid.NewGuid().ToString(), + Key = key, + Value = value + }; + } + + set.Score = score; + + repository.Save(set); + } + } + + public override void RemoveFromSet(string key, string value) + { + using (var repository = new Repository()) { + var set = repository.Session.Query().FirstOrDefault(t => t.Key == key && t.Value == value); + + if (set == null) { + return; + } + + repository.Delete(set); + } + } + + public override void InsertToList(string key, string value) + { + using (var repository = new Repository()) { + var list = repository.Session.Query().FirstOrDefault(t => t.Key == key && t.Value == value); + + if (list == null) { + list = new RavenList + { + Id = Guid.NewGuid().ToString(), + Key = key + }; + } + + list.Value = value; + + repository.Save(list); + } + } + + public override void RemoveFromList(string key, string value) + { + using (var repository = new Repository()) { + var list = repository.Session.Query().FirstOrDefault(t => t.Key == key && t.Value == value); + + if (list == null) { + return; + } + + repository.Delete(list); + } + } + + public override void TrimList(string key, int keepStartingFrom, int keepEndingAt) + { + using (var repository = new Repository()) { + var list = repository.Session.Query().Where(t => t.Key == key).ToList(); + + if (!list.Any()) { + return; + } + + for (var i = 0; i < list.Count; ++i) { + if (i >= keepStartingFrom && i <= keepEndingAt) { + continue; + } + + repository.Delete(list[i]); + } + } + } + + public override void SetRangeInHash(string key, IEnumerable> keyValuePairs) + { + key.ThrowIfNull("key"); + keyValuePairs.ThrowIfNull("keyValuePairs"); + + using (var repository = new Repository()) { + foreach (var keyValuePair in keyValuePairs) { + var pair = keyValuePair; + + var results = repository.Session.Query().Where(t => t.Key == key && t.Field == pair.Key).ToList(); + + if (!results.Any()) { + results.Add(new RavenHash + { + Key = key, + Field = pair.Key, + }); + } + + foreach (var item in results) { + item.Value = pair.Value; + + repository.Save(item); + } + } + } + } + + public override void RemoveHash(string key) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var result = repository.Session.Query().Where(t => t.Key == key).ToList(); + + foreach (var item in result) { + repository.Session.Delete(item); + } + } + } + + public override void RemoveSet(string key) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var result = repository.Session.Query().Where(t => t.Key == key).ToList(); + + foreach (var item in result) { + repository.Session.Delete(item); + } + } + } + + public override void ExpireHash(string key, TimeSpan expireIn) + { + key.ThrowIfNull("key"); + + using (var repository = new Repository()) { + var result = repository.Session.Query().Where(t => t.Key == key).ToList(); + + foreach (var item in result) { + item.ExpireAt = DateTime.UtcNow.Add(expireIn); + + repository.Save(item); + } + } + } + + internal void QueueCommand(Action action) + { + _commandQueue.Enqueue(action); + } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/Repository.cs b/src/Hangfire.SqlServer/Repository.cs new file mode 100644 index 0000000..7acd9d8 --- /dev/null +++ b/src/Hangfire.SqlServer/Repository.cs @@ -0,0 +1,358 @@ +using Raven.Client; +using System; +using Dewey.Net.Types; +using Raven.Client.Document; +using Raven.Client.Embedded; +using System.Threading.Tasks; +using System.Collections.Generic; +using Dewey.Net.Generic; +using Raven.Abstractions.Data; +using System.Collections.Concurrent; +using Raven.Client.Linq; +using System.Linq; +using System.Linq.Expressions; +using Hangfire.Raven.Entities.Identity; +using Hangfire.Raven.Extensions; +using Hangfire.Raven.Listeners; + +namespace HangFire.Raven +{ + public class Repository : IDisposable + { + public static string ConnectionUrl { get; set; } + public static string DefaultDatabase { get; set; } + + private static IDocumentStore _documentStore; + private IDocumentSession _session; + private IAsyncDocumentSession _asyncSession; + private string _database; + + public static bool Embedded { get; set; } + + public string Database => _database ?? DefaultDatabase; + + public void Destroy() + { + if (!DocumentStore.DatabaseExists(Database)) { + return; + } + + DocumentStore.DatabaseCommands.GlobalAdmin.DeleteDatabase(Database, hardDelete: true); + } + + public void Create() + { + if (DocumentStore.DatabaseExists(Database)) { + return; + } + + DocumentStore.DatabaseCommands.GlobalAdmin.EnsureDatabaseExists(Database); + } + + public IDocumentStore DocumentStore + { + get + { + if (_documentStore == null) { + if (Embedded) { + _documentStore = new EmbeddableDocumentStore + { + RunInMemory = true + }; + + ((EmbeddableDocumentStore)_documentStore).Configuration.Storage.Voron.AllowOn32Bits = true; + } else { + _documentStore = new DocumentStore + { + Url = ConnectionUrl + }; + } + + _documentStore.Conventions.DefaultQueryingConsistency = ConsistencyOptions.AlwaysWaitForNonStaleResultsAsOfLastWrite; + _documentStore.Listeners.RegisterListener(new TakeNewestConflictResolutionListener()); + _documentStore.Conventions.DefaultQueryingConsistency = ConsistencyOptions.AlwaysWaitForNonStaleResultsAsOfLastWrite; + + _documentStore.Initialize(); + } + + return _documentStore; + } + } + + public IDocumentSession Session => _session ?? (_session = DocumentStore.OpenSession(Database)); + + public IAsyncDocumentSession AsyncSession => _asyncSession ?? (_asyncSession = DocumentStore.OpenAsyncSession(Database)); + + #region constructor + + public Repository() + { + _database = DefaultDatabase; + } + + public Repository(string database) + { + if (database.IsEmpty()) { + _database = DefaultDatabase; + } else { + _database = database; + } + } + + #endregion + + #region Bulk Insert + + private BulkInsertOptions GetBulkInsertOptions(bool overwrite = true) => new BulkInsertOptions + { + BatchSize = 100, + WriteTimeoutMilliseconds = 360000, + OverwriteExisting = overwrite + }; + + public void BulkInsert(ConcurrentBag entities, bool overwrite = true) where T : BaseEntity + { + if (_database.IsEmpty()) { + _database = DefaultDatabase; + } + + using (var bulkInsert = DocumentStore.BulkInsert(Database, GetBulkInsertOptions(overwrite))) { + foreach (var item in entities) { + if (item == null) { + continue; + } + + if (item.Id.IsEmpty()) { + item.Id = Guid.NewGuid().ToString(); + } + + bulkInsert.Store(item); + } + } + } + + public void BulkInsert(List entities, bool overwrite = true) where T : BaseEntity + { + var concurrentBag = new ConcurrentBag(); + + entities.ForEach(t => concurrentBag.Add(t)); + + BulkInsert(concurrentBag, overwrite); + } + + #endregion + + #region Find + + public T FindById(string id) where T : BaseEntity + { + if (id == null) { + throw new ArgumentException("Id must be provided.", nameof(id)); + } + + return Session.Load(id); + } + + public async Task FindByIdAsync(string id) where T : BaseEntity + { + if (id == null) { + throw new ArgumentException("Id must be provided.", nameof(id)); + } + + return await AsyncSession.LoadAsync(id); + } + + #endregion + + #region Save + + public void Save(T entity) where T : BaseEntity + { + if (entity == null) { + throw new ArgumentException("Entity must be provided.", nameof(entity)); + } + + if (entity.Id.IsEmpty()) { + entity.Id = Guid.NewGuid().ToString(); + } + + Session.Store(entity); + Session.SaveChanges(); + } + + public async Task SaveAsync(T entity) where T : BaseEntity + { + if (entity == null) { + throw new ArgumentException("Entity must be provided.", nameof(entity)); + } + + if (entity.Id.IsEmpty()) { + entity.Id = Guid.NewGuid().ToString(); + } + + await AsyncSession.StoreAsync(entity); + await AsyncSession.SaveChangesAsync(); + } + + public async Task SaveNoValidateAsync(T entity) where T : BaseEntity + { + if (entity == null) { + throw new ArgumentException("Entity must be provided.", nameof(entity)); + } + + if (entity.Id.IsEmpty()) { + entity.Id = Guid.NewGuid().ToString(); + } + + await AsyncSession.StoreAsync(entity); + await AsyncSession.SaveChangesAsync(); + } + + public void SaveAll(List entities) where T : BaseEntity + { + if (entities.IsEmpty()) { + throw new ArgumentException("Data must be provided.", nameof(entities)); + } + + foreach (var item in entities) { + Save(item); + } + } + + public async Task SaveAllAsync(List entities) where T : BaseEntity + { + if (entities.IsEmpty()) { + throw new ArgumentException("Data must be provided.", nameof(entities)); + } + + foreach (var item in entities) { + await SaveAsync(item); + } + } + + #endregion + + #region Delete + + public void Delete(string id) + { + if (id == null) { + throw new ArgumentException("Id must be provided.", nameof(id)); + } + + Session.Delete(id); + + Session.SaveChanges(); + } + + public async Task DeleteAsync(string id) + { + if (id == null) { + throw new ArgumentException("Id must be provided.", nameof(id)); + } + + AsyncSession.Delete(id); + + await AsyncSession.SaveChangesAsync(); + } + + public void Delete(T entity) where T : BaseEntity + { + if (entity == null) { + throw new ArgumentException("Entity must be provided.", nameof(entity)); + } + + Session.Delete(entity.Id); + + Session.SaveChanges(); + } + + public async Task DeleteAsync(T entity) where T : BaseEntity + { + if (entity == null) { + throw new ArgumentException("Entity must be provided.", nameof(entity)); + } + + AsyncSession.Delete(entity.Id); + + await AsyncSession.SaveChangesAsync(); + } + + #endregion + + #region Query + + public IRavenQueryable Query() where T : BaseEntity + { + return Session.Query(); + } + + public IRavenQueryable QueryAsync() where T : BaseEntity + { + return AsyncSession.Query(); + } + + #endregion + + #region Index + + public IRavenQueryable Index(string indexName, bool isMapReduce = false) where T : BaseEntity + { + return Session.Query(indexName, isMapReduce); + } + + public IRavenQueryable IndexAsync(string indexName, bool isMapReduce = false) where T : BaseEntity + { + return AsyncSession.Query(indexName, isMapReduce); + } + + #endregion + + #region Count + + public int Count() where T : BaseEntity + { + return Session.Query().Count(); + } + + public async Task CountAsync() where T : BaseEntity + { + return await AsyncSession.Query().CountAsync(); + } + + public int Count(Expression> predicate) where T : BaseEntity + { + return Session.Query().Where(predicate).Count(); + } + + public async Task CountAsync(Expression> predicate) where T : BaseEntity + { + return await AsyncSession.Query().Where(predicate).CountAsync(); + } + + #endregion + + #region IDisposable + + private bool _disposed = false; + + protected void Dispose(bool disposing) + { + if (!_disposed) { + if (disposing) { + _session = null; + _asyncSession = null; + } + + _disposed = true; + } + } + + public void Dispose() + { + Dispose(true); + } + + #endregion + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/SqlServerMonitoringApi.cs b/src/Hangfire.SqlServer/SqlServerMonitoringApi.cs new file mode 100644 index 0000000..c7df715 --- /dev/null +++ b/src/Hangfire.SqlServer/SqlServerMonitoringApi.cs @@ -0,0 +1,562 @@ +//// This file is part of Hangfire. +//// Copyright © 2013-2014 Sergey Odinokov. +//// +//// Hangfire is free software: you can redistribute it and/or modify +//// it under the terms of the GNU Lesser General Public License as +//// published by the Free Software Foundation, either version 3 +//// of the License, or any later version. +//// +//// Hangfire is distributed in the hope that it will be useful, +//// but WITHOUT ANY WARRANTY; without even the implied warranty of +//// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +//// GNU Lesser General Public License for more details. +//// +//// You should have received a copy of the GNU Lesser General Public +//// License along with Hangfire. If not, see . + +//using System; +//using System.Collections.Generic; +//using System.Data.SqlClient; +//using System.Linq; +//using System.Transactions; +//using Dapper; +//using Hangfire.Annotations; +//using Hangfire.Common; +//using Hangfire.SqlServer.Entities; +//using Hangfire.States; +//using Hangfire.Storage; +//using Hangfire.Storage.Monitoring; + +//namespace Hangfire.SqlServer +//{ +// internal class SqlServerMonitoringApi : IMonitoringApi +// { +// private readonly RavenStorage _storage; +// private readonly int? _jobListLimit; + +// public SqlServerMonitoringApi([NotNull] RavenStorage storage, int? jobListLimit) +// { +// if (storage == null) throw new ArgumentNullException("storage"); + +// _storage = storage; +// _jobListLimit = jobListLimit; +// } + +// public long ScheduledCount() +// { +// return UseConnection(connection => +// GetNumberOfJobsByStateName(connection, ScheduledState.StateName)); +// } + +// public long EnqueuedCount(string queue) +// { +// var queueApi = GetQueueApi(queue); +// var counters = queueApi.GetEnqueuedAndFetchedCount(queue); + +// return counters.EnqueuedCount ?? 0; +// } + +// public long FetchedCount(string queue) +// { +// var queueApi = GetQueueApi(queue); +// var counters = queueApi.GetEnqueuedAndFetchedCount(queue); + +// return counters.FetchedCount ?? 0; +// } + +// public long FailedCount() +// { +// return UseConnection(connection => +// GetNumberOfJobsByStateName(connection, FailedState.StateName)); +// } + +// public long ProcessingCount() +// { +// return UseConnection(connection => +// GetNumberOfJobsByStateName(connection, ProcessingState.StateName)); +// } + +// public JobList ProcessingJobs(int @from, int count) +// { +// return UseConnection(connection => GetJobs( +// connection, +// from, count, +// ProcessingState.StateName, +// (sqlJob, job, stateData) => new ProcessingJobDto +// { +// Job = job, +// ServerId = stateData.ContainsKey("ServerId") ? stateData["ServerId"] : stateData["ServerName"], +// StartedAt = JobHelper.DeserializeDateTime(stateData["StartedAt"]), +// })); +// } + +// public JobList ScheduledJobs(int @from, int count) +// { +// return UseConnection(connection => GetJobs( +// connection, +// from, count, +// ScheduledState.StateName, +// (sqlJob, job, stateData) => new ScheduledJobDto +// { +// Job = job, +// EnqueueAt = JobHelper.DeserializeDateTime(stateData["EnqueueAt"]), +// ScheduledAt = JobHelper.DeserializeDateTime(stateData["ScheduledAt"]) +// })); +// } + +// public IDictionary SucceededByDatesCount() +// { +// return UseConnection(connection => +// GetTimelineStats(connection, "succeeded")); +// } + +// public IDictionary FailedByDatesCount() +// { +// return UseConnection(connection => +// GetTimelineStats(connection, "failed")); +// } + +// public IList Servers() +// { +// return UseConnection>(connection => +// { +// var servers = connection.Query( +// string.Format(@"select * from [{0}].Server", _storage.GetSchemaName())) +// .ToList(); + +// var result = new List(); + +// foreach (var server in servers) +// { +// var data = JobHelper.FromJson(server.Data); +// result.Add(new ServerDto +// { +// Name = server.Id, +// Heartbeat = server.LastHeartbeat, +// Queues = data.Queues, +// StartedAt = data.StartedAt.HasValue ? data.StartedAt.Value : DateTime.MinValue, +// WorkersCount = data.WorkerCount +// }); +// } + +// return result; +// }); +// } + +// public JobList FailedJobs(int @from, int count) +// { +// return UseConnection(connection => GetJobs( +// connection, +// from, +// count, +// FailedState.StateName, +// (sqlJob, job, stateData) => new FailedJobDto +// { +// Job = job, +// Reason = sqlJob.StateReason, +// ExceptionDetails = stateData["ExceptionDetails"], +// ExceptionMessage = stateData["ExceptionMessage"], +// ExceptionType = stateData["ExceptionType"], +// FailedAt = JobHelper.DeserializeNullableDateTime(stateData["FailedAt"]) +// })); +// } + +// public JobList SucceededJobs(int @from, int count) +// { +// return UseConnection(connection => GetJobs( +// connection, +// from, +// count, +// SucceededState.StateName, +// (sqlJob, job, stateData) => new SucceededJobDto +// { +// Job = job, +// Result = stateData.ContainsKey("Result") ? stateData["Result"] : null, +// TotalDuration = stateData.ContainsKey("PerformanceDuration") && stateData.ContainsKey("Latency") +// ? (long?)long.Parse(stateData["PerformanceDuration"]) + (long?)long.Parse(stateData["Latency"]) +// : null, +// SucceededAt = JobHelper.DeserializeNullableDateTime(stateData["SucceededAt"]) +// })); +// } + +// public JobList DeletedJobs(int @from, int count) +// { +// return UseConnection(connection => GetJobs( +// connection, +// from, +// count, +// DeletedState.StateName, +// (sqlJob, job, stateData) => new DeletedJobDto +// { +// Job = job, +// DeletedAt = JobHelper.DeserializeNullableDateTime(stateData["DeletedAt"]) +// })); +// } + +// public IList Queues() +// { +// var tuples = _storage.QueueProviders +// .Select(x => x.GetJobQueueMonitoringApi()) +// .SelectMany(x => x.GetQueues(), (monitoring, queue) => new { Monitoring = monitoring, Queue = queue }) +// .OrderBy(x => x.Queue) +// .ToArray(); + +// var result = new List(tuples.Length); + +// foreach (var tuple in tuples) +// { +// var enqueuedJobIds = tuple.Monitoring.GetEnqueuedJobIds(tuple.Queue, 0, 5); +// var counters = tuple.Monitoring.GetEnqueuedAndFetchedCount(tuple.Queue); + +// var firstJobs = UseConnection(connection => EnqueuedJobs(connection, enqueuedJobIds)); + +// result.Add(new QueueWithTopEnqueuedJobsDto +// { +// Name = tuple.Queue, +// Length = counters.EnqueuedCount ?? 0, +// Fetched = counters.FetchedCount, +// FirstJobs = firstJobs +// }); +// } + +// return result; +// } + +// public JobList EnqueuedJobs(string queue, int @from, int perPage) +// { +// var queueApi = GetQueueApi(queue); +// var enqueuedJobIds = queueApi.GetEnqueuedJobIds(queue, from, perPage); + +// return UseConnection(connection => EnqueuedJobs(connection, enqueuedJobIds)); +// } + +// public JobList FetchedJobs(string queue, int @from, int perPage) +// { +// var queueApi = GetQueueApi(queue); +// var fetchedJobIds = queueApi.GetFetchedJobIds(queue, from, perPage); + +// return UseConnection(connection => FetchedJobs(connection, fetchedJobIds)); +// } + +// public IDictionary HourlySucceededJobs() +// { +// return UseConnection(connection => +// GetHourlyTimelineStats(connection, "succeeded")); +// } + +// public IDictionary HourlyFailedJobs() +// { +// return UseConnection(connection => +// GetHourlyTimelineStats(connection, "failed")); +// } + +// public JobDetailsDto JobDetails(string jobId) +// { +// return UseConnection(connection => +// { + +// string sql = string.Format(@" +//select * from [{0}].Job where Id = @id +//select * from [{0}].JobParameter where JobId = @id +//select * from [{0}].State where JobId = @id order by Id desc", _storage.GetSchemaName()); + +// using (var multi = connection.QueryMultiple(sql, new { id = jobId })) +// { +// var job = multi.Read().SingleOrDefault(); +// if (job == null) return null; + +// var parameters = multi.Read().ToDictionary(x => x.Name, x => x.Value); +// var history = +// multi.Read() +// .ToList() +// .Select(x => new StateHistoryDto +// { +// StateName = x.Name, +// CreatedAt = x.CreatedAt, +// Reason = x.Reason, +// Data = new Dictionary( +// JobHelper.FromJson>(x.Data), +// StringComparer.OrdinalIgnoreCase), +// }) +// .ToList(); + +// return new JobDetailsDto +// { +// CreatedAt = job.CreatedAt, +// ExpireAt = job.ExpireAt, +// Job = DeserializeJob(job.InvocationData, job.Arguments), +// History = history, +// Properties = parameters +// }; +// } +// }); +// } + +// public long SucceededListCount() +// { +// return UseConnection(connection => +// GetNumberOfJobsByStateName(connection, SucceededState.StateName)); +// } + +// public long DeletedListCount() +// { +// return UseConnection(connection => +// GetNumberOfJobsByStateName(connection, DeletedState.StateName)); +// } + +// public StatisticsDto GetStatistics() +// { +// string sql = string.Format(@" +//select count(Id) from [{0}].Job where StateName = N'Enqueued'; +//select count(Id) from [{0}].Job where StateName = N'Failed'; +//select count(Id) from [{0}].Job where StateName = N'Processing'; +//select count(Id) from [{0}].Job where StateName = N'Scheduled'; +//select count(Id) from [{0}].Server; +//select sum(s.[Value]) from ( +// select sum([Value]) as [Value] from [{0}].Counter where [Key] = N'stats:succeeded' +// union all +// select [Value] from [{0}].AggregatedCounter where [Key] = N'stats:succeeded' +//) as s; +//select sum(s.[Value]) from ( +// select sum([Value]) as [Value] from [{0}].Counter where [Key] = N'stats:deleted' +// union all +// select [Value] from [{0}].AggregatedCounter where [Key] = N'stats:deleted' +//) as s; +//select count(*) from [{0}].[Set] where [Key] = N'recurring-jobs'; +//", _storage.GetSchemaName()); + +// var statistics = UseConnection(connection => +// { +// var stats = new StatisticsDto(); +// using (var multi = connection.QueryMultiple(sql)) +// { +// stats.Enqueued = multi.Read().Single(); +// stats.Failed = multi.Read().Single(); +// stats.Processing = multi.Read().Single(); +// stats.Scheduled = multi.Read().Single(); + +// stats.Servers = multi.Read().Single(); + +// stats.Succeeded = multi.Read().SingleOrDefault() ?? 0; +// stats.Deleted = multi.Read().SingleOrDefault() ?? 0; + +// stats.Recurring = multi.Read().Single(); +// } +// return stats; +// }); + +// statistics.Queues = _storage.QueueProviders +// .SelectMany(x => x.GetJobQueueMonitoringApi().GetQueues()) +// .Count(); + +// return statistics; +// } + +// private Dictionary GetHourlyTimelineStats( +// SqlConnection connection, +// string type) +// { +// var endDate = DateTime.UtcNow; +// var dates = new List(); +// for (var i = 0; i < 24; i++) +// { +// dates.Add(endDate); +// endDate = endDate.AddHours(-1); +// } + +// var keyMaps = dates.ToDictionary(x => String.Format("stats:{0}:{1}", type, x.ToString("yyyy-MM-dd-HH")), x => x); + +// return GetTimelineStats(connection, keyMaps); +// } + +// private Dictionary GetTimelineStats( +// SqlConnection connection, +// string type) +// { +// var endDate = DateTime.UtcNow.Date; +// var dates = new List(); +// for (var i = 0; i < 7; i++) +// { +// dates.Add(endDate); +// endDate = endDate.AddDays(-1); +// } + +// var keyMaps = dates.ToDictionary(x => String.Format("stats:{0}:{1}", type, x.ToString("yyyy-MM-dd")), x => x); + +// return GetTimelineStats(connection, keyMaps); +// } + +// private Dictionary GetTimelineStats(SqlConnection connection, +// IDictionary keyMaps) +// { +// string sqlQuery = string.Format(@" +//select [Key], [Value] as [Count] from [{0}].AggregatedCounter +//where [Key] in @keys", _storage.GetSchemaName()); + +// var valuesMap = connection.Query( +// sqlQuery, +// new { keys = keyMaps.Keys }) +// .ToDictionary(x => (string)x.Key, x => (long)x.Count); + +// foreach (var key in keyMaps.Keys) +// { +// if (!valuesMap.ContainsKey(key)) valuesMap.Add(key, 0); +// } + +// var result = new Dictionary(); +// for (var i = 0; i < keyMaps.Count; i++) +// { +// var value = valuesMap[keyMaps.ElementAt(i).Key]; +// result.Add(keyMaps.ElementAt(i).Value, value); +// } + +// return result; +// } + +// private IPersistentJobQueueMonitoringApi GetQueueApi(string queueName) +// { +// var provider = _storage.QueueProviders.GetProvider(queueName); +// var monitoringApi = provider.GetJobQueueMonitoringApi(); + +// return monitoringApi; +// } + +// private T UseConnection(Func action) +// { +// return _storage.UseTransaction(action, IsolationLevel.ReadUncommitted); +// } + +// private JobList EnqueuedJobs( +// SqlConnection connection, +// IEnumerable jobIds) +// { +// string enqueuedJobsSql = string.Format(@" +//select j.*, s.Reason as StateReason, s.Data as StateData +//from [{0}].Job j +//left join [{0}].State s on s.Id = j.StateId +//where j.Id in @jobIds", _storage.GetSchemaName()); + +// var jobs = connection.Query( +// enqueuedJobsSql, +// new { jobIds = jobIds }) +// .ToList(); + +// return DeserializeJobs( +// jobs, +// (sqlJob, job, stateData) => new EnqueuedJobDto +// { +// Job = job, +// State = sqlJob.StateName, +// EnqueuedAt = sqlJob.StateName == EnqueuedState.StateName +// ? JobHelper.DeserializeNullableDateTime(stateData["EnqueuedAt"]) +// : null +// }); +// } + +// private long GetNumberOfJobsByStateName(SqlConnection connection, string stateName) +// { +// var sqlQuery = _jobListLimit.HasValue +// ? string.Format(@"select count(j.Id) from (select top (@limit) Id from [{0}].Job where StateName = @state) as j", _storage.GetSchemaName()) +// : string.Format(@"select count(Id) from [{0}].Job where StateName = @state", _storage.GetSchemaName()); + +// var count = connection.Query( +// sqlQuery, +// new { state = stateName, limit = _jobListLimit }) +// .Single(); + +// return count; +// } + +// private static Job DeserializeJob(string invocationData, string arguments) +// { +// var data = JobHelper.FromJson(invocationData); +// data.Arguments = arguments; + +// try +// { +// return data.Deserialize(); +// } +// catch (JobLoadException) +// { +// return null; +// } +// } + +// private JobList GetJobs( +// SqlConnection connection, +// int from, +// int count, +// string stateName, +// Func, TDto> selector) +// { +// string jobsSql = string.Format(@" +//select * from ( +// select j.*, s.Reason as StateReason, s.Data as StateData, row_number() over (order by j.Id desc) as row_num +// from [{0}].Job j with (forceseek) +// left join [{0}].State s on j.StateId = s.Id +// where j.StateName = @stateName +//) as j where j.row_num between @start and @end +//", _storage.GetSchemaName()); + +// var jobs = connection.Query( +// jobsSql, +// new { stateName = stateName, start = @from + 1, end = @from + count }) +// .ToList(); + +// return DeserializeJobs(jobs, selector); +// } + +// private static JobList DeserializeJobs( +// ICollection jobs, +// Func, TDto> selector) +// { +// var result = new List>(jobs.Count); + +// foreach (var job in jobs) +// { +// var deserializedData = JobHelper.FromJson>(job.StateData); +// var stateData = deserializedData != null +// ? new Dictionary(deserializedData, StringComparer.OrdinalIgnoreCase) +// : null; + +// var dto = selector(job, DeserializeJob(job.InvocationData, job.Arguments), stateData); + +// result.Add(new KeyValuePair( +// job.Id.ToString(), dto)); +// } + +// return new JobList(result); +// } + +// private JobList FetchedJobs( +// SqlConnection connection, +// IEnumerable jobIds) +// { +// string fetchedJobsSql = string.Format(@" +//select j.*, s.Reason as StateReason, s.Data as StateData +//from [{0}].Job j +//left join [{0}].State s on s.Id = j.StateId +//where j.Id in @jobIds", _storage.GetSchemaName()); + +// var jobs = connection.Query( +// fetchedJobsSql, +// new { jobIds = jobIds }) +// .ToList(); + +// var result = new List>(jobs.Count); + +// foreach (var job in jobs) +// { +// result.Add(new KeyValuePair( +// job.Id.ToString(), +// new FetchedJobDto +// { +// Job = DeserializeJob(job.InvocationData, job.Arguments), +// State = job.StateName, +// })); +// } + +// return new JobList(result); +// } +// } +//} diff --git a/src/Hangfire.SqlServer/StateHandlers/DeletedStateHandler.cs b/src/Hangfire.SqlServer/StateHandlers/DeletedStateHandler.cs new file mode 100644 index 0000000..d71897c --- /dev/null +++ b/src/Hangfire.SqlServer/StateHandlers/DeletedStateHandler.cs @@ -0,0 +1,24 @@ +using Hangfire.States; +using Hangfire.Storage; + +namespace Hangfire.Raven.StateHandlers +{ + public class DeletedStateHandler : IStateHandler + { + public void Apply(ApplyStateContext context, IWriteOnlyTransaction transaction) + { + transaction.InsertToList("deleted", context.JobId); + transaction.TrimList("deleted", 0, 99); + } + + public void Unapply(ApplyStateContext context, IWriteOnlyTransaction transaction) + { + transaction.RemoveFromList("deleted", context.JobId); + } + + public string StateName + { + get { return DeletedState.StateName; } + } + } +} diff --git a/src/Hangfire.SqlServer/StateHandlers/FailedStateHandler.cs b/src/Hangfire.SqlServer/StateHandlers/FailedStateHandler.cs new file mode 100644 index 0000000..0cca9f4 --- /dev/null +++ b/src/Hangfire.SqlServer/StateHandlers/FailedStateHandler.cs @@ -0,0 +1,25 @@ +using Hangfire.Common; +using Hangfire.States; +using Hangfire.Storage; +using System; + +namespace Hangfire.Raven.StateHandlers +{ + public class FailedStateHandler : IStateHandler + { + public void Apply(ApplyStateContext context, IWriteOnlyTransaction transaction) + { + transaction.AddToSet("failed", context.JobId, JobHelper.ToTimestamp(DateTime.UtcNow)); + } + + public void Unapply(ApplyStateContext context, IWriteOnlyTransaction transaction) + { + transaction.RemoveFromSet("failed", context.JobId); + } + + public string StateName + { + get { return FailedState.StateName; } + } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/StateHandlers/ProcessingStateHandler.cs b/src/Hangfire.SqlServer/StateHandlers/ProcessingStateHandler.cs new file mode 100644 index 0000000..a647de4 --- /dev/null +++ b/src/Hangfire.SqlServer/StateHandlers/ProcessingStateHandler.cs @@ -0,0 +1,25 @@ +using Hangfire.Common; +using Hangfire.States; +using Hangfire.Storage; +using System; + +namespace Hangfire.Raven.StateHandlers +{ + public class ProcessingStateHandler : IStateHandler + { + public void Apply(ApplyStateContext context, IWriteOnlyTransaction transaction) + { + transaction.AddToSet("processing", context.JobId, JobHelper.ToTimestamp(DateTime.UtcNow)); + } + + public void Unapply(ApplyStateContext context, IWriteOnlyTransaction transaction) + { + transaction.RemoveFromSet("processing", context.JobId); + } + + public string StateName + { + get { return ProcessingState.StateName; } + } + } +} diff --git a/src/Hangfire.SqlServer/StateHandlers/SucceededStateHandler.cs b/src/Hangfire.SqlServer/StateHandlers/SucceededStateHandler.cs new file mode 100644 index 0000000..9f721e8 --- /dev/null +++ b/src/Hangfire.SqlServer/StateHandlers/SucceededStateHandler.cs @@ -0,0 +1,24 @@ +using Hangfire.States; +using Hangfire.Storage; + +namespace Hangfire.Raven.StateHandlers +{ + public class SucceededStateHandler : IStateHandler + { + public void Apply(ApplyStateContext context, IWriteOnlyTransaction transaction) + { + transaction.InsertToList("succeeded", context.JobId); + transaction.TrimList("succeeded", 0, 99); + } + + public void Unapply(ApplyStateContext context, IWriteOnlyTransaction transaction) + { + transaction.RemoveFromList("succeeded", context.JobId); + } + + public string StateName + { + get { return SucceededState.StateName; } + } + } +} diff --git a/src/Hangfire.SqlServer/Storage/RavenStorage.cs b/src/Hangfire.SqlServer/Storage/RavenStorage.cs new file mode 100644 index 0000000..513e870 --- /dev/null +++ b/src/Hangfire.SqlServer/Storage/RavenStorage.cs @@ -0,0 +1,87 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System; +using System.Collections.Generic; +using Hangfire.Logging; +using Hangfire.Server; +using Hangfire.Storage; +using Raven.Client.Document; +using Hangfire.Raven.JobQueues; + +namespace Hangfire.Raven.Storage +{ + public class RavenStorage : JobStorage + { + private readonly RavenStorageOptions _options; + + public RavenStorage() + : this(new RavenStorageOptions()) + { + } + + /// + /// Initializes RavenStorage from the provided SqlServerStorageOptions and either the provided connection + /// string or the connection string with provided name pulled from the application config file. + /// + /// + /// + /// argument is null. + /// argument is null. + /// argument is neither + /// a valid Raven connection string nor the name of a connection string in the application + /// config file. + public RavenStorage(RavenStorageOptions options) + { + options.ThrowIfNull("options"); + + _options = options; + + InitializeQueueProviders(); + } + + public virtual PersistentJobQueueProviderCollection QueueProviders { get; private set; } + + public override IMonitoringApi GetMonitoringApi() + { + //return new SqlServerMonitoringApi(this, _options.DashboardJobListLimit); + + return null; + } + + public override IStorageConnection GetConnection() + { + return new RavenConnection(this); + } + + public override IEnumerable GetComponents() + { + yield return new ExpirationManager(_options.JobExpirationCheckInterval); + } + + public override void WriteOptionsToLog(ILog logger) + { + logger.Info("Using the following options for Raven job storage:"); + logger.InfoFormat("Queue poll interval: {0}.", _options.QueuePollInterval); + } + + private void InitializeQueueProviders() + { + var defaultQueueProvider = new RavenJobQueueProvider(this, _options); + QueueProviders = new PersistentJobQueueProviderCollection(defaultQueueProvider); + } + } +} \ No newline at end of file diff --git a/src/Hangfire.SqlServer/Storage/RavenStorageExtensions.cs b/src/Hangfire.SqlServer/Storage/RavenStorageExtensions.cs new file mode 100644 index 0000000..7f67897 --- /dev/null +++ b/src/Hangfire.SqlServer/Storage/RavenStorageExtensions.cs @@ -0,0 +1,87 @@ +// This file is part of Hangfire. +// Copyright © 2015 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System; +using Hangfire; +using Hangfire.Raven.Storage; +using Hangfire.Raven; + +// ReSharper disable once CheckNamespace +namespace HangFire.Raven.Storage +{ + public static class SqlServerStorageExtensions + { + public static IGlobalConfiguration UseRavenStorage(this IGlobalConfiguration configuration, string connectionUrl, string database) + { + configuration.ThrowIfNull("configuration"); + connectionUrl.ThrowIfNull("connectionUrl"); + database.ThrowIfNull("database"); + + if (!connectionUrl.StartsWith("http")) { + throw new ArgumentException("Connection Url must begin with http or https!"); + } + + Repository.ConnectionUrl = connectionUrl; + Repository.DefaultDatabase = database; + + var storage = new RavenStorage(); + + return configuration.UseStorage(storage); + } + + public static IGlobalConfiguration UseRavenStorage(this IGlobalConfiguration configuration, string connectionUrl, string database, RavenStorageOptions options) + { + configuration.ThrowIfNull("configuration"); + connectionUrl.ThrowIfNull("connectionUrl"); + database.ThrowIfNull("database"); + options.ThrowIfNull("options"); + + if (!connectionUrl.StartsWith("http")) { + throw new ArgumentException("Connection Url must begin with http or https!"); + } + + Repository.ConnectionUrl = connectionUrl; + Repository.DefaultDatabase = database; + + var storage = new RavenStorage(options); + + return configuration.UseStorage(storage); + } + + public static IGlobalConfiguration UseEmbeddedRavenStorage(this IGlobalConfiguration configuration) + { + configuration.ThrowIfNull("configuration"); + + Repository.Embedded = true; + + var storage = new RavenStorage(); + + return configuration.UseStorage(storage); + } + + public static IGlobalConfiguration UseEmbeddedRavenStorage(this IGlobalConfiguration configuration, RavenStorageOptions options) + { + configuration.ThrowIfNull("configuration"); + options.ThrowIfNull("options"); + + Repository.Embedded = true; + + var storage = new RavenStorage(options); + + return configuration.UseStorage(storage); + } + } +} diff --git a/src/Hangfire.SqlServer/Storage/RavenStorageOptions.cs b/src/Hangfire.SqlServer/Storage/RavenStorageOptions.cs new file mode 100644 index 0000000..be12324 --- /dev/null +++ b/src/Hangfire.SqlServer/Storage/RavenStorageOptions.cs @@ -0,0 +1,80 @@ +// This file is part of Hangfire. +// Copyright © 2013-2014 Sergey Odinokov. +// +// Hangfire is free software: you can redistribute it and/or modify +// it under the terms of the GNU Lesser General Public License as +// published by the Free Software Foundation, either version 3 +// of the License, or any later version. +// +// Hangfire is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Lesser General Public License for more details. +// +// You should have received a copy of the GNU Lesser General Public +// License along with Hangfire. If not, see . + +using System; + +namespace Hangfire.Raven.Storage +{ + public class RavenStorageOptions + { + private readonly string _clientId = null; + + private TimeSpan _queuePollInterval; + + public RavenStorageOptions() + { + QueuePollInterval = TimeSpan.FromSeconds(15); + InvisibilityTimeout = TimeSpan.FromMinutes(30); + JobExpirationCheckInterval = TimeSpan.FromHours(1); + CountersAggregateInterval = TimeSpan.FromMinutes(5); + PrepareSchemaIfNecessary = true; + DashboardJobListLimit = 50000; + TransactionTimeout = TimeSpan.FromMinutes(1); + DistributedLockLifetime = TimeSpan.FromSeconds(30); + + _clientId = Guid.NewGuid().ToString().Replace("-", String.Empty); + } + + public TimeSpan QueuePollInterval + { + get { return _queuePollInterval; } + set + { + var message = string.Format( + "The QueuePollInterval property value should be positive. Given: {0}.", + value); + + if (value == TimeSpan.Zero) + { + throw new ArgumentException(message, "value"); + } + if (value != value.Duration()) + { + throw new ArgumentException(message, "value"); + } + + _queuePollInterval = value; + } + } + + [Obsolete("Does not make sense anymore. Background jobs re-queued instantly even after ungraceful shutdown now. Will be removed in 2.0.0.")] + public TimeSpan InvisibilityTimeout { get; set; } + + public bool PrepareSchemaIfNecessary { get; set; } + + public TimeSpan JobExpirationCheckInterval { get; set; } + public TimeSpan CountersAggregateInterval { get; set; } + + public int? DashboardJobListLimit { get; set; } + public TimeSpan TransactionTimeout { get; set; } + public TimeSpan DistributedLockLifetime { get; set; } + + public string ClientId + { + get { return _clientId; } + } + } +} diff --git a/src/Hangfire.SqlServer/app.config b/src/Hangfire.SqlServer/app.config new file mode 100644 index 0000000..195db1f --- /dev/null +++ b/src/Hangfire.SqlServer/app.config @@ -0,0 +1,11 @@ + + + + + + + + + + + \ No newline at end of file diff --git a/src/Hangfire.SqlServer/packages.config b/src/Hangfire.SqlServer/packages.config new file mode 100644 index 0000000..f611865 --- /dev/null +++ b/src/Hangfire.SqlServer/packages.config @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/SharedAssemblyInfo.cs b/src/SharedAssemblyInfo.cs new file mode 100644 index 0000000..490beeb --- /dev/null +++ b/src/SharedAssemblyInfo.cs @@ -0,0 +1,14 @@ +using System; +using System.Reflection; +using System.Runtime.InteropServices; + +[assembly: AssemblyProduct("Hangfire")] +[assembly: AssemblyCompany("Sergey Odinokov")] +[assembly: AssemblyCopyright("Copyright © 2013-2015 Sergey Odinokov")] +[assembly: AssemblyCulture("")] + +[assembly: ComVisible(false)] +[assembly: CLSCompliant(false)] + +// Don't edit manually! Use `build.bat version` command instead! +[assembly: AssemblyVersion("1.5.0")] diff --git a/tests/Hangfire.Mongo.Tests/ExpirationManagerFacts.cs b/tests/Hangfire.Mongo.Tests/ExpirationManagerFacts.cs new file mode 100644 index 0000000..9d1c26b --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/ExpirationManagerFacts.cs @@ -0,0 +1,230 @@ +using Hangfire.Mongo.Database; +using Hangfire.Mongo.Dto; +using Hangfire.Mongo.MongoUtils; +using Hangfire.Mongo.Tests.Utils; +using MongoDB.Bson; +using MongoDB.Bson.Serialization; +using MongoDB.Driver; +using MongoDB.Driver.Builders; +using System; +using System.Threading; +using Xunit; + +namespace Hangfire.Mongo.Tests +{ + public class ExpirationManagerFacts + { + private readonly MongoStorage _storage; + + private readonly CancellationToken _token; + + public ExpirationManagerFacts() + { + _storage = new MongoStorage(ConnectionUtils.GetConnectionString(), ConnectionUtils.GetDatabaseName()); + _token = new CancellationToken(true); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenStorageIsNull() + { + Assert.Throws(() => new ExpirationManager(null)); + } + + [Fact, CleanDatabase] + public void Execute_RemovesOutdatedRecords() + { + using (var connection = ConnectionUtils.CreateConnection()) + { + var entryId = CreateExpirationEntry(connection, connection.GetServerTimeUtc().AddMonths(-1)); + var manager = CreateManager(); + + manager.Execute(_token); + + Assert.True(IsEntryExpired(connection, entryId)); + } + } + + [Fact, CleanDatabase] + public void Execute_DoesNotRemoveEntries_WithNoExpirationTimeSet() + { + using (var connection = ConnectionUtils.CreateConnection()) + { + var entryId = CreateExpirationEntry(connection, null); + var manager = CreateManager(); + + manager.Execute(_token); + + Assert.False(IsEntryExpired(connection, entryId)); + } + } + + [Fact, CleanDatabase] + public void Execute_DoesNotRemoveEntries_WithFreshExpirationTime() + { + using (var connection = ConnectionUtils.CreateConnection()) + { + var entryId = CreateExpirationEntry(connection, DateTime.Now.AddMonths(1)); + var manager = CreateManager(); + + manager.Execute(_token); + + Assert.False(IsEntryExpired(connection, entryId)); + } + } + + [Fact, CleanDatabase] + public void Execute_Processes_CounterTable() + { + using (var connection = ConnectionUtils.CreateConnection()) + { + // Arrange + connection.Counter.Insert(new CounterDto + { + Id = ObjectId.GenerateNewId(), + Key = "key", + Value = 1, + ExpireAt = connection.GetServerTimeUtc().AddMonths(-1) + }); + + var manager = CreateManager(); + + // Act + manager.Execute(_token); + + // Assert + var count = connection.Counter.Count(); + Assert.Equal(0, count); + } + } + + [Fact, CleanDatabase] + public void Execute_Processes_JobTable() + { + using (var connection = ConnectionUtils.CreateConnection()) + { + // Arrange + connection.Job.Insert(new JobDto + { + Id = 1, + InvocationData = "", + Arguments = "", + CreatedAt = connection.GetServerTimeUtc(), + ExpireAt = connection.GetServerTimeUtc().AddMonths(-1), + }); + + var manager = CreateManager(); + + // Act + manager.Execute(_token); + + // Assert + var count = connection.Job.Count(); + Assert.Equal(0, count); + } + } + + [Fact, CleanDatabase] + public void Execute_Processes_ListTable() + { + using (var connection = ConnectionUtils.CreateConnection()) + { + // Arrange + connection.List.Insert(new ListDto + { + Id = ObjectId.GenerateNewId(), + Key = "key", + ExpireAt = connection.GetServerTimeUtc().AddMonths(-1) + }); + + var manager = CreateManager(); + + // Act + manager.Execute(_token); + + // Assert + var count = connection.List.Count(); + Assert.Equal(0, count); + } + } + + [Fact, CleanDatabase] + public void Execute_Processes_SetTable() + { + using (var connection = ConnectionUtils.CreateConnection()) + { + // Arrange + connection.Set.Insert(new SetDto + { + Id = ObjectId.GenerateNewId(), + Key = "key", + Score = 0, + Value = "", + ExpireAt = connection.GetServerTimeUtc().AddMonths(-1) + }); + + var manager = CreateManager(); + + // Act + manager.Execute(_token); + + // Assert + var count = connection.Set.Count(); + Assert.Equal(0, count); + } + } + + [Fact, CleanDatabase] + public void Execute_Processes_HashTable() + { + using (var connection = ConnectionUtils.CreateConnection()) + { + // Arrange + connection.Hash.Insert(new HashDto + { + Id = ObjectId.GenerateNewId(), + Key = "key", + Field = "field", + Value = "", + ExpireAt = connection.GetServerTimeUtc().AddMonths(-1) + }); + + var manager = CreateManager(); + + // Act + manager.Execute(_token); + + // Assert + var count = connection.Hash.Count(); + Assert.Equal(0, count); + } + } + + + private static ObjectId CreateExpirationEntry(HangfireDbContext connection, DateTime? expireAt) + { + var counter = new CounterDto + { + Id = ObjectId.GenerateNewId(), + Key = "key", + Value = 1, + ExpireAt = expireAt + }; + connection.Counter.Insert(counter); + + var id = counter.Id; + + return id; + } + + private static bool IsEntryExpired(HangfireDbContext connection, ObjectId entryId) + { + var count = connection.Counter.Count(Query.EQ(_ => _.Id, entryId)); + return count == 0; + } + + private ExpirationManager CreateManager() + { + return new ExpirationManager(_storage); + } + } +} \ No newline at end of file diff --git a/tests/Hangfire.Mongo.Tests/Hangfire.Mongo.Tests.csproj b/tests/Hangfire.Mongo.Tests/Hangfire.Mongo.Tests.csproj new file mode 100644 index 0000000..7d8e17e --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/Hangfire.Mongo.Tests.csproj @@ -0,0 +1,103 @@ + + + + + Debug + AnyCPU + {237F15A4-26DB-4FBD-86AF-81E87AD5762A} + Library + Properties + Hangfire.Mongo.Tests + Hangfire.Mongo.Tests + v4.5.1 + 512 + ..\..\ + true + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + False + ..\..\packages\Hangfire.Core.1.3.0\lib\net45\Hangfire.Core.dll + + + False + ..\..\packages\mongocsharpdriver.1.9.2\lib\net35\MongoDB.Bson.dll + + + False + ..\..\packages\mongocsharpdriver.1.9.2\lib\net35\MongoDB.Driver.dll + + + ..\..\packages\Moq.4.2.1409.1722\lib\net40\Moq.dll + + + ..\..\packages\Newtonsoft.Json.5.0.1\lib\net45\Newtonsoft.Json.dll + + + False + ..\..\packages\Owin.1.0\lib\net40\Owin.dll + + + + + + + + + + ..\..\packages\xunit.1.9.2\lib\net20\xunit.dll + + + + + + + + + + + + + + + + + + Designer + + + + + {1fe9c86e-db7a-4c1b-b73a-42b993d68688} + Hangfire.Mongo + + + + + + + + \ No newline at end of file diff --git a/tests/Hangfire.Mongo.Tests/MongoConnectionFacts.cs b/tests/Hangfire.Mongo.Tests/MongoConnectionFacts.cs new file mode 100644 index 0000000..ebac6c5 --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/MongoConnectionFacts.cs @@ -0,0 +1,869 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using Hangfire.Common; +using Hangfire.Mongo.Database; +using Hangfire.Mongo.Dto; +using Hangfire.Mongo.MongoUtils; +using Hangfire.Mongo.PersistentJobQueue; +using Hangfire.Mongo.Tests.Utils; +using Hangfire.Server; +using Hangfire.Storage; +using MongoDB.Bson; +using MongoDB.Driver.Builders; +using Moq; +using Xunit; + +namespace Hangfire.Mongo.Tests +{ + public class MongoConnectionFacts + { + private readonly Mock _queue; + private readonly Mock _provider; + private readonly PersistentJobQueueProviderCollection _providers; + + public MongoConnectionFacts() + { + _queue = new Mock(); + + _provider = new Mock(); + _provider.Setup(x => x.GetJobQueue(It.IsNotNull())).Returns(_queue.Object); + + _providers = new PersistentJobQueueProviderCollection(_provider.Object); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenConnectionIsNull() + { + var exception = Assert.Throws( + () => new MongoConnection(null, _providers)); + + Assert.Equal("database", exception.ParamName); + } + + [Fact, CleanDatabase] + public void Ctor_ThrowsAnException_WhenProvidersCollectionIsNull() + { + var exception = Assert.Throws( + () => new MongoConnection(ConnectionUtils.CreateConnection(), null)); + + Assert.Equal("queueProviders", exception.ParamName); + } + + + [Fact, CleanDatabase] + public void FetchNextJob_DelegatesItsExecution_ToTheQueue() + { + UseConnection((database, connection) => + { + var token = new CancellationToken(); + var queues = new[] { "default" }; + + connection.FetchNextJob(queues, token); + + _queue.Verify(x => x.Dequeue(queues, token)); + }); + } + + [Fact, CleanDatabase] + public void FetchNextJob_Throws_IfMultipleProvidersResolved() + { + UseConnection((database, connection) => + { + var token = new CancellationToken(); + var anotherProvider = new Mock(); + _providers.Add(anotherProvider.Object, new[] { "critical" }); + + Assert.Throws( + () => connection.FetchNextJob(new[] { "critical", "default" }, token)); + }); + } + + [Fact, CleanDatabase] + public void CreateWriteTransaction_ReturnsNonNullInstance() + { + UseConnection((database, connection) => + { + var transaction = connection.CreateWriteTransaction(); + Assert.NotNull(transaction); + }); + } + + [Fact, CleanDatabase] + public void AcquireLock_ReturnsNonNullInstance() + { + UseConnection((database, connection) => + { + var @lock = connection.AcquireDistributedLock("1", TimeSpan.FromSeconds(1)); + Assert.NotNull(@lock); + }); + } + + [Fact, CleanDatabase] + public void CreateExpiredJob_ThrowsAnException_WhenJobIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.CreateExpiredJob( + null, + new Dictionary(), + database.GetServerTimeUtc(), + TimeSpan.Zero)); + + Assert.Equal("job", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void CreateExpiredJob_ThrowsAnException_WhenParametersCollectionIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.CreateExpiredJob( + Job.FromExpression(() => SampleMethod("hello")), + null, + database.GetServerTimeUtc(), + TimeSpan.Zero)); + + Assert.Equal("parameters", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void CreateExpiredJob_CreatesAJobInTheStorage_AndSetsItsParameters() + { + UseConnection((database, connection) => + { + var createdAt = new DateTime(2012, 12, 12, 0, 0, 0, 0, DateTimeKind.Utc); + var jobId = connection.CreateExpiredJob(Job.FromExpression(() => SampleMethod("Hello")), + new Dictionary { { "Key1", "Value1" }, { "Key2", "Value2" } }, + createdAt, + TimeSpan.FromDays(1)); + + Assert.NotNull(jobId); + Assert.NotEmpty(jobId); + + var databaseJob = database.Job.FindAll().Single(); + Assert.Equal(jobId, databaseJob.Id.ToString()); + Assert.Equal(createdAt, databaseJob.CreatedAt); + Assert.Equal(ObjectId.Empty, databaseJob.StateId); + Assert.Equal(null, databaseJob.StateName); + + var invocationData = JobHelper.FromJson((string)databaseJob.InvocationData); + invocationData.Arguments = databaseJob.Arguments; + + var job = invocationData.Deserialize(); + Assert.Equal(typeof(MongoConnectionFacts), job.Type); + Assert.Equal("SampleMethod", job.Method.Name); + Assert.Equal("\"Hello\"", job.Arguments[0]); + + Assert.True(createdAt.AddDays(1).AddMinutes(-1) < databaseJob.ExpireAt); + Assert.True(databaseJob.ExpireAt < createdAt.AddDays(1).AddMinutes(1)); + + var parameters = database.JobParameter.Find(Query.EQ(_ => _.JobId, int.Parse(jobId))) + .ToDictionary(x => x.Name, x => x.Value); + + Assert.Equal("Value1", parameters["Key1"]); + Assert.Equal("Value2", parameters["Key2"]); + }); + } + + [Fact, CleanDatabase] + public void GetJobData_ThrowsAnException_WhenJobIdIsNull() + { + UseConnection((database, connection) => Assert.Throws( + () => connection.GetJobData(null))); + } + + [Fact, CleanDatabase] + public void GetJobData_ReturnsNull_WhenThereIsNoSuchJob() + { + UseConnection((database, connection) => + { + var result = connection.GetJobData("547527b4c6b6cc26a02d021d"); + Assert.Null(result); + }); + } + + [Fact, CleanDatabase] + public void GetJobData_ReturnsResult_WhenJobExists() + { + UseConnection((database, connection) => + { + var job = Job.FromExpression(() => SampleMethod("wrong")); + + var jobDto = new JobDto + { + Id = 1, + InvocationData = JobHelper.ToJson(InvocationData.Serialize(job)), + Arguments = "['Arguments']", + StateName = "Succeeded", + CreatedAt = database.GetServerTimeUtc() + }; + database.Job.Insert(jobDto); + + var result = connection.GetJobData(jobDto.Id.ToString()); + + Assert.NotNull(result); + Assert.NotNull(result.Job); + Assert.Equal("Succeeded", result.State); + Assert.Equal("Arguments", result.Job.Arguments[0]); + Assert.Null(result.LoadException); + Assert.True(database.GetServerTimeUtc().AddMinutes(-1) < result.CreatedAt); + Assert.True(result.CreatedAt < DateTime.UtcNow.AddMinutes(1)); + }); + } + + [Fact, CleanDatabase] + public void GetStateData_ThrowsAnException_WhenJobIdIsNull() + { + UseConnection( + (database, connection) => Assert.Throws( + () => connection.GetStateData(null))); + } + + [Fact, CleanDatabase] + public void GetStateData_ReturnsNull_IfThereIsNoSuchState() + { + UseConnection((database, connection) => + { + var result = connection.GetStateData("547527b4c6b6cc26a02d021d"); + Assert.Null(result); + }); + } + + [Fact, CleanDatabase] + public void GetStateData_ReturnsCorrectData() + { + UseConnection((database, connection) => + { + var data = new Dictionary + { + { "Key", "Value" } + }; + + var jobDto = new JobDto + { + Id = 1, + InvocationData = "", + Arguments = "", + StateName = "", + CreatedAt = database.GetServerTimeUtc() + }; + + database.Job.Insert(jobDto); + var jobId = jobDto.Id; + + database.State.Insert(new StateDto + { + Id = ObjectId.GenerateNewId(), + JobId = jobId, + Name = "old-state", + CreatedAt = database.GetServerTimeUtc() + }); + + var stateDto = new StateDto + { + Id = ObjectId.GenerateNewId(), + JobId = jobId, + Name = "Name", + Reason = "Reason", + Data = JobHelper.ToJson(data), + CreatedAt = database.GetServerTimeUtc() + }; + database.State.Insert(stateDto); + + jobDto.StateId = stateDto.Id; + database.Job.Save(jobDto); + + var result = connection.GetStateData(jobId.ToString()); + Assert.NotNull(result); + + Assert.Equal("Name", result.Name); + Assert.Equal("Reason", result.Reason); + Assert.Equal("Value", result.Data["Key"]); + }); + } + + [Fact, CleanDatabase] + public void GetJobData_ReturnsJobLoadException_IfThereWasADeserializationException() + { + UseConnection((database, connection) => + { + var jobDto = new JobDto + { + Id = 1, + InvocationData = JobHelper.ToJson(new InvocationData(null, null, null, null)), + Arguments = "['Arguments']", + StateName = "Succeeded", + CreatedAt = database.GetServerTimeUtc() + }; + database.Job.Insert(jobDto); + var jobId = jobDto.Id; + + var result = connection.GetJobData(jobId.ToString()); + + Assert.NotNull(result.LoadException); + }); + } + + [Fact, CleanDatabase] + public void SetParameter_ThrowsAnException_WhenJobIdIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.SetJobParameter(null, "name", "value")); + + Assert.Equal("id", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetParameter_ThrowsAnException_WhenNameIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.SetJobParameter("547527b4c6b6cc26a02d021d", null, "value")); + + Assert.Equal("name", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetParameters_CreatesNewParameter_WhenParameterWithTheGivenNameDoesNotExists() + { + UseConnection((database, connection) => + { + var jobDto = new JobDto + { + Id = 1, + InvocationData = "", + Arguments = "", + CreatedAt = database.GetServerTimeUtc() + }; + database.Job.Insert(jobDto); + string jobId = jobDto.Id.ToString(); + + connection.SetJobParameter(jobId, "Name", "Value"); + + var parameter = database.JobParameter.FindOne(Query.And(Query.EQ(_ => _.JobId, int.Parse(jobId)), + Query.EQ(_ => _.Name, "Name"))); + + Assert.Equal("Value", parameter.Value); + }); + } + + [Fact, CleanDatabase] + public void SetParameter_UpdatesValue_WhenParameterWithTheGivenName_AlreadyExists() + { + UseConnection((database, connection) => + { + var jobDto = new JobDto + { + Id = 1, + InvocationData = "", + Arguments = "", + CreatedAt = database.GetServerTimeUtc() + }; + database.Job.Insert(jobDto); + string jobId = jobDto.Id.ToString(); + + connection.SetJobParameter(jobId, "Name", "Value"); + connection.SetJobParameter(jobId, "Name", "AnotherValue"); + + var parameter = database.JobParameter.FindOne(Query.And(Query.EQ(_ => _.JobId, int.Parse(jobId)), + Query.EQ(_ => _.Name, "Name"))); + + Assert.Equal("AnotherValue", parameter.Value); + }); + } + + [Fact, CleanDatabase] + public void SetParameter_CanAcceptNulls_AsValues() + { + UseConnection((database, connection) => + { + var jobDto = new JobDto + { + Id = 1, + InvocationData = "", + Arguments = "", + CreatedAt = database.GetServerTimeUtc() + }; + database.Job.Insert(jobDto); + string jobId = jobDto.Id.ToString(); + + connection.SetJobParameter(jobId, "Name", null); + + var parameter = database.JobParameter.FindOne(Query.And(Query.EQ(_ => _.JobId, int.Parse(jobId)), + Query.EQ(_ => _.Name, "Name"))); + + Assert.Equal(null, parameter.Value); + }); + } + + [Fact, CleanDatabase] + public void GetParameter_ThrowsAnException_WhenJobIdIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.GetJobParameter(null, "hello")); + + Assert.Equal("id", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void GetParameter_ThrowsAnException_WhenNameIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.GetJobParameter("547527b4c6b6cc26a02d021d", null)); + + Assert.Equal("name", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void GetParameter_ReturnsNull_WhenParameterDoesNotExists() + { + UseConnection((database, connection) => + { + var value = connection.GetJobParameter("1", "hello"); + Assert.Null(value); + }); + } + + [Fact, CleanDatabase] + public void GetParameter_ReturnsParameterValue_WhenJobExists() + { + UseConnection((database, connection) => + { + var jobDto = new JobDto + { + Id = 1, + InvocationData = "", + Arguments = "", + CreatedAt = database.GetServerTimeUtc() + }; + database.Job.Insert(jobDto); + string jobId = jobDto.Id.ToString(); + + database.JobParameter.Insert(new JobParameterDto + { + Id = ObjectId.GenerateNewId(), + JobId = int.Parse(jobId), + Name = "name", + Value = "value" + }); + + var value = connection.GetJobParameter(jobId, "name"); + + Assert.Equal("value", value); + }); + } + + [Fact, CleanDatabase] + public void GetFirstByLowestScoreFromSet_ThrowsAnException_WhenKeyIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.GetFirstByLowestScoreFromSet(null, 0, 1)); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void GetFirstByLowestScoreFromSet_ThrowsAnException_ToScoreIsLowerThanFromScore() + { + UseConnection((database, connection) => Assert.Throws( + () => connection.GetFirstByLowestScoreFromSet("key", 0, -1))); + } + + [Fact, CleanDatabase] + public void GetFirstByLowestScoreFromSet_ReturnsNull_WhenTheKeyDoesNotExist() + { + UseConnection((database, connection) => + { + var result = connection.GetFirstByLowestScoreFromSet( + "key", 0, 1); + + Assert.Null(result); + }); + } + + [Fact, CleanDatabase] + public void GetFirstByLowestScoreFromSet_ReturnsTheValueWithTheLowestScore() + { + UseConnection((database, connection) => + { + database.Set.Insert(new SetDto + { + Id = ObjectId.GenerateNewId(), + Key = "key", + Score = 1.0, + Value = "1.0" + }); + database.Set.Insert(new SetDto + { + Id = ObjectId.GenerateNewId(), + Key = "key", + Score = -1.0, + Value = "-1.0" + }); + database.Set.Insert(new SetDto + { + Id = ObjectId.GenerateNewId(), + Key = "key", + Score = -5.0, + Value = "-5.0" + }); + database.Set.Insert(new SetDto + { + Id = ObjectId.GenerateNewId(), + Key = "another-key", + Score = -2.0, + Value = "-2.0" + }); + + var result = connection.GetFirstByLowestScoreFromSet("key", -1.0, 3.0); + + Assert.Equal("-1.0", result); + }); + } + + [Fact, CleanDatabase] + public void AnnounceServer_ThrowsAnException_WhenServerIdIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.AnnounceServer(null, new ServerContext())); + + Assert.Equal("serverId", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void AnnounceServer_ThrowsAnException_WhenContextIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.AnnounceServer("server", null)); + + Assert.Equal("context", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void AnnounceServer_CreatesOrUpdatesARecord() + { + UseConnection((database, connection) => + { + var context1 = new ServerContext + { + Queues = new[] { "critical", "default" }, + WorkerCount = 4 + }; + connection.AnnounceServer("server", context1); + + var server = database.Server.FindAll().Single(); + Assert.Equal("server", server.Id); + Assert.True(((string)server.Data).StartsWith( + "{\"WorkerCount\":4,\"Queues\":[\"critical\",\"default\"],\"StartedAt\":"), + server.Data); + Assert.NotNull(server.LastHeartbeat); + + var context2 = new ServerContext + { + Queues = new[] { "default" }, + WorkerCount = 1000 + }; + connection.AnnounceServer("server", context2); + var sameServer = database.Server.FindAll().Single(); + Assert.Equal("server", sameServer.Id); + Assert.Contains("1000", sameServer.Data); + }); + } + + [Fact, CleanDatabase] + public void RemoveServer_ThrowsAnException_WhenServerIdIsNull() + { + UseConnection((database, connection) => Assert.Throws( + () => connection.RemoveServer(null))); + } + + [Fact, CleanDatabase] + public void RemoveServer_RemovesAServerRecord() + { + UseConnection((database, connection) => + { + database.Server.Insert(new ServerDto + { + Id = "Server1", + Data = "", + LastHeartbeat = database.GetServerTimeUtc() + }); + database.Server.Insert(new ServerDto + { + Id = "Server2", + Data = "", + LastHeartbeat = database.GetServerTimeUtc() + }); + + connection.RemoveServer("Server1"); + + var server = database.Server.FindAll().Single(); + Assert.NotEqual("Server1", server.Id, StringComparer.OrdinalIgnoreCase); + }); + } + + [Fact, CleanDatabase] + public void Heartbeat_ThrowsAnException_WhenServerIdIsNull() + { + UseConnection((database, connection) => Assert.Throws( + () => connection.Heartbeat(null))); + } + + [Fact, CleanDatabase] + public void Heartbeat_UpdatesLastHeartbeat_OfTheServerWithGivenId() + { + UseConnection((database, connection) => + { + database.Server.Insert(new ServerDto + { + Id = "server1", + Data = "", + LastHeartbeat = new DateTime(2012, 12, 12, 12, 12, 12, DateTimeKind.Utc) + }); + database.Server.Insert(new ServerDto + { + Id = "server2", + Data = "", + LastHeartbeat = new DateTime(2012, 12, 12, 12, 12, 12, DateTimeKind.Utc) + }); + + connection.Heartbeat("server1"); + + var servers = database.Server.FindAll() + .ToDictionary(x => x.Id, x => x.LastHeartbeat); + + Assert.NotEqual(2012, servers["server1"].Year); + Assert.Equal(2012, servers["server2"].Year); + }); + } + + [Fact, CleanDatabase] + public void RemoveTimedOutServers_ThrowsAnException_WhenTimeOutIsNegative() + { + UseConnection((database, connection) => Assert.Throws( + () => connection.RemoveTimedOutServers(TimeSpan.FromMinutes(-5)))); + } + + [Fact, CleanDatabase] + public void RemoveTimedOutServers_DoItsWorkPerfectly() + { + UseConnection((database, connection) => + { + database.Server.Insert(new ServerDto + { + Id = "server1", + Data = "", + LastHeartbeat = database.GetServerTimeUtc().AddDays(-1) + }); + database.Server.Insert(new ServerDto + { + Id = "server2", + Data = "", + LastHeartbeat = database.GetServerTimeUtc().AddHours(-12) + }); + + connection.RemoveTimedOutServers(TimeSpan.FromHours(15)); + + var liveServer = database.Server.FindAll().Single(); + Assert.Equal("server2", liveServer.Id); + }); + } + + [Fact, CleanDatabase] + public void GetAllItemsFromSet_ThrowsAnException_WhenKeyIsNull() + { + UseConnection((database, connection) => + Assert.Throws(() => connection.GetAllItemsFromSet(null))); + } + + [Fact, CleanDatabase] + public void GetAllItemsFromSet_ReturnsEmptyCollection_WhenKeyDoesNotExist() + { + UseConnection((database, connection) => + { + var result = connection.GetAllItemsFromSet("some-set"); + + Assert.NotNull(result); + Assert.Equal(0, result.Count); + }); + } + + [Fact, CleanDatabase] + public void GetAllItemsFromSet_ReturnsAllItems() + { + UseConnection((database, connection) => + { + // Arrange + database.Set.Insert(new SetDto + { + Id = ObjectId.GenerateNewId(), + Key = "some-set", + Score = 0.0, + Value = "1" + }); + database.Set.Insert(new SetDto + { + Id = ObjectId.GenerateNewId(), + Key = "some-set", + Score = 0.0, + Value = "2" + }); + database.Set.Insert(new SetDto + { + Id = ObjectId.GenerateNewId(), + Key = "another-set", + Score = 0.0, + Value = "3" + }); + + // Act + var result = connection.GetAllItemsFromSet("some-set"); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains("1", result); + Assert.Contains("2", result); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_ThrowsAnException_WhenKeyIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.SetRangeInHash(null, new Dictionary())); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_ThrowsAnException_WhenKeyValuePairsArgumentIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.SetRangeInHash("some-hash", null)); + + Assert.Equal("keyValuePairs", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_MergesAllRecords() + { + UseConnection((database, connection) => + { + connection.SetRangeInHash("some-hash", new Dictionary + { + { "Key1", "Value1" }, + { "Key2", "Value2" } + }); + + var result = database.Hash.Find(Query.EQ(_=>_.Key, "some-hash")) + .ToDictionary(x => x.Field, x => x.Value); + + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["Key2"]); + }); + } + + [Fact, CleanDatabase] + public void GetAllEntriesFromHash_ThrowsAnException_WhenKeyIsNull() + { + UseConnection((database, connection) => + Assert.Throws(() => connection.GetAllEntriesFromHash(null))); + } + + [Fact, CleanDatabase] + public void GetAllEntriesFromHash_ReturnsNull_IfHashDoesNotExist() + { + UseConnection((database, connection) => + { + var result = connection.GetAllEntriesFromHash("some-hash"); + Assert.Null(result); + }); + } + + [Fact, CleanDatabase] + public void GetAllEntriesFromHash_ReturnsAllKeysAndTheirValues() + { + UseConnection((database, connection) => + { + // Arrange + database.Hash.Insert(new HashDto + { + Id = ObjectId.GenerateNewId(), + Key = "some-hash", + Field = "Key1", + Value = "Value1" + }); + database.Hash.Insert(new HashDto + { + Id = ObjectId.GenerateNewId(), + Key = "some-hash", + Field = "Key2", + Value = "Value2" + }); + database.Hash.Insert(new HashDto + { + Id = ObjectId.GenerateNewId(), + Key = "another-hash", + Field = "Key3", + Value = "Value3" + }); + + // Act + var result = connection.GetAllEntriesFromHash("some-hash"); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["Key2"]); + }); + } + + private void UseConnection(Action action) + { + using (var database = ConnectionUtils.CreateConnection()) + { + using (var connection = new MongoConnection(database, _providers)) + { + action(database, connection); + } + } + } + + public static void SampleMethod(string arg) + { + } + } +} \ No newline at end of file diff --git a/tests/Hangfire.Mongo.Tests/MongoDistributedLockFacts.cs b/tests/Hangfire.Mongo.Tests/MongoDistributedLockFacts.cs new file mode 100644 index 0000000..921a539 --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/MongoDistributedLockFacts.cs @@ -0,0 +1,119 @@ +using System; +using System.Threading; +using Hangfire.Mongo.Database; +using Hangfire.Mongo.Dto; +using Hangfire.Mongo.MongoUtils; +using Hangfire.Mongo.Tests.Utils; +using MongoDB.Driver.Builders; +using Xunit; +using Hangfire.Mongo.DistributedLock; + +namespace Hangfire.Mongo.Tests +{ + public class MongoDistributedLockFacts + { + [Fact] + public void Ctor_ThrowsAnException_WhenResourceIsNull() + { + UseConnection(database => + { + var exception = Assert.Throws( + () => new MongoDistributedLock(null, TimeSpan.FromSeconds(1), database, new MongoStorageOptions())); + + Assert.Equal("resource", exception.ParamName); + }); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenConnectionIsNull() + { + var exception = Assert.Throws( + () => new MongoDistributedLock("resource1", TimeSpan.FromSeconds(1), null, new MongoStorageOptions())); + + Assert.Equal("database", exception.ParamName); + } + + [Fact, CleanDatabase] + public void Ctor_SetLock_WhenResourceIsNotLocked() + { + UseConnection(database => + { + using (MongoDistributedLock @lock = new MongoDistributedLock("resource1", TimeSpan.FromSeconds(1), database, new MongoStorageOptions())) + { + var locksCount = database.DistributedLock.Count(Query.EQ(_ => _.Resource, "resource1")); + Assert.Equal(1, locksCount); + } + }); + } + + [Fact, CleanDatabase] + public void Ctor_SetReleaseLock_WhenResourceIsNotLocked() + { + UseConnection(database => + { + long locksCount; + using (MongoDistributedLock @lock = new MongoDistributedLock("resource1", TimeSpan.FromSeconds(1), database, new MongoStorageOptions())) + { + locksCount = database.DistributedLock.Count(Query.EQ(_ => _.Resource, "resource1")); + Assert.Equal(1, locksCount); + } + + locksCount = database.DistributedLock.Count(Query.EQ(_ => _.Resource, "resource1")); + Assert.Equal(0, locksCount); + }); + } + + [Fact, CleanDatabase] + public void Ctor_ThrowsAnException_WhenResourceIsLocked() + { + UseConnection(database => + { + using (MongoDistributedLock @lock1 = new MongoDistributedLock("resource1", TimeSpan.FromSeconds(1), database, new MongoStorageOptions())) + { + var locksCount = database.DistributedLock.Count(Query.EQ(_ => _.Resource, "resource1")); + Assert.Equal(1, locksCount); + + Assert.Throws(() => new MongoDistributedLock("resource1", TimeSpan.FromSeconds(1), database, new MongoStorageOptions())); + } + }); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenOptionsIsNull() + { + UseConnection(database => + { + var exception = Assert.Throws( + () => new MongoDistributedLock("resource1", TimeSpan.FromSeconds(1), database, null)); + + Assert.Equal("options", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void Ctor_SetLockHeartbeatWorks_WhenResourceIsNotLocked() + { + UseConnection(database => + { + using (MongoDistributedLock @lock = new MongoDistributedLock("resource1", TimeSpan.FromSeconds(1), database, new MongoStorageOptions() { DistributedLockLifetime = TimeSpan.FromSeconds(10)})) + { + DateTime initialHeartBeat = database.GetServerTimeUtc(); + Thread.Sleep(TimeSpan.FromSeconds(3)); + Thread.Sleep(TimeSpan.FromSeconds(60)); + + DistributedLockDto lockEntry = database.DistributedLock.FindOne(Query.EQ(_ => _.Resource, "resource1")); + Assert.NotNull(lockEntry); + Assert.True(lockEntry.Heartbeat > initialHeartBeat); + } + }); + } + + private static void UseConnection(Action action) + { + using (var connection = ConnectionUtils.CreateConnection()) + { + action(connection); + } + } + } +} \ No newline at end of file diff --git a/tests/Hangfire.Mongo.Tests/MongoFetchedJobFacts.cs b/tests/Hangfire.Mongo.Tests/MongoFetchedJobFacts.cs new file mode 100644 index 0000000..f70d712 --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/MongoFetchedJobFacts.cs @@ -0,0 +1,164 @@ +using System; +using System.Data; +using System.Linq; +using Hangfire.Mongo.Database; +using Hangfire.Mongo.Dto; +using Hangfire.Mongo.MongoUtils; +using Hangfire.Mongo.Tests.Utils; +using Moq; +using Xunit; + +namespace Hangfire.Mongo.Tests +{ + public class MongoFetchedJobFacts + { + private const string JobId = "id"; + private const string Queue = "queue"; + + + [Fact] + public void Ctor_ThrowsAnException_WhenConnectionIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => new MongoFetchedJob(null, 1, JobId, Queue)); + + Assert.Equal("connection", exception.ParamName); + }); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenJobIdIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws(() => new MongoFetchedJob(connection, 1, null, Queue)); + + Assert.Equal("jobId", exception.ParamName); + }); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenQueueIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => new MongoFetchedJob(connection, 1, JobId, null)); + + Assert.Equal("queue", exception.ParamName); + }); + } + + [Fact] + public void Ctor_CorrectlySets_AllInstanceProperties() + { + UseConnection(connection => + { + var fetchedJob = new MongoFetchedJob(connection, 1, JobId, Queue); + + Assert.Equal(1, fetchedJob.Id); + Assert.Equal(JobId, fetchedJob.JobId); + Assert.Equal(Queue, fetchedJob.Queue); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromQueue_ReallyDeletesTheJobFromTheQueue() + { + UseConnection(connection => + { + // Arrange + var id = CreateJobQueueRecord(connection, "1", "default"); + var processingJob = new MongoFetchedJob(connection, id, "1", "default"); + + // Act + processingJob.RemoveFromQueue(); + + // Assert + var count = connection.JobQueue.Count(); + Assert.Equal(0, count); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromQueue_DoesNotDelete_UnrelatedJobs() + { + UseConnection(connection => + { + // Arrange + CreateJobQueueRecord(connection, "1", "default"); + CreateJobQueueRecord(connection, "1", "critical"); + CreateJobQueueRecord(connection, "2", "default"); + + var fetchedJob = new MongoFetchedJob(connection, 999, "1", "default"); + + // Act + fetchedJob.RemoveFromQueue(); + + // Assert + var count = connection.JobQueue.Count(); + Assert.Equal(3, count); + }); + } + + [Fact, CleanDatabase] + public void Requeue_SetsFetchedAtValueToNull() + { + UseConnection(connection => + { + // Arrange + var id = CreateJobQueueRecord(connection, "1", "default"); + var processingJob = new MongoFetchedJob(connection, id, "1", "default"); + + // Act + processingJob.Requeue(); + + // Assert + var record = connection.JobQueue.FindAll().Single(); + Assert.Null(record.FetchedAt); + }); + } + + [Fact, CleanDatabase] + public void Dispose_SetsFetchedAtValueToNull_IfThereWereNoCallsToComplete() + { + UseConnection(sql => + { + // Arrange + var id = CreateJobQueueRecord(sql, "1", "default"); + var processingJob = new MongoFetchedJob(sql, id, "1", "default"); + + // Act + processingJob.Dispose(); + + // Assert + var record = sql.JobQueue.FindAll().Single(); + Assert.Null(record.FetchedAt); + }); + } + + private static int CreateJobQueueRecord(HangfireDbContext connection, string jobId, string queue) + { + var jobQueue = new JobQueueDto + { + JobId = int.Parse(jobId), + Queue = queue, + FetchedAt = connection.GetServerTimeUtc() + }; + + connection.JobQueue.Insert(jobQueue); + + return jobQueue.Id; + } + + private static void UseConnection(Action action) + { + using (var connection = ConnectionUtils.CreateConnection()) + { + action(connection); + } + } + } +} \ No newline at end of file diff --git a/tests/Hangfire.Mongo.Tests/MongoJobQueueFacts.cs b/tests/Hangfire.Mongo.Tests/MongoJobQueueFacts.cs new file mode 100644 index 0000000..edcfd45 --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/MongoJobQueueFacts.cs @@ -0,0 +1,344 @@ +using Hangfire.Mongo.Database; +using Hangfire.Mongo.Dto; +using Hangfire.Mongo.MongoUtils; +using Hangfire.Mongo.PersistentJobQueue.Mongo; +using Hangfire.Mongo.Tests.Utils; +using MongoDB.Driver.Builders; +using System; +using System.Linq; +using System.Threading; +using Xunit; + +namespace Hangfire.Mongo.Tests +{ + public class MongoJobQueueFacts + { + private static readonly string[] DefaultQueues = { "default" }; + + [Fact] + public void Ctor_ThrowsAnException_WhenConnectionIsNull() + { + var exception = Assert.Throws( + () => new MongoJobQueue(null, new MongoStorageOptions())); + + Assert.Equal("connection", exception.ParamName); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenOptionsValueIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => new MongoJobQueue(connection, null)); + + Assert.Equal("options", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldThrowAnException_WhenQueuesCollectionIsNull() + { + UseConnection(connection => + { + var queue = CreateJobQueue(connection); + + var exception = Assert.Throws( + () => queue.Dequeue(null, CreateTimingOutCancellationToken())); + + Assert.Equal("queues", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldThrowAnException_WhenQueuesCollectionIsEmpty() + { + UseConnection(connection => + { + var queue = CreateJobQueue(connection); + + var exception = Assert.Throws( + () => queue.Dequeue(new string[0], CreateTimingOutCancellationToken())); + + Assert.Equal("queues", exception.ParamName); + }); + } + + [Fact] + public void Dequeue_ThrowsOperationCanceled_WhenCancellationTokenIsSetAtTheBeginning() + { + UseConnection(connection => + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + var queue = CreateJobQueue(connection); + + Assert.Throws(() => queue.Dequeue(DefaultQueues, cts.Token)); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldWaitIndefinitely_WhenThereAreNoJobs() + { + UseConnection(connection => + { + var cts = new CancellationTokenSource(200); + var queue = CreateJobQueue(connection); + + Assert.Throws(() => queue.Dequeue(DefaultQueues, cts.Token)); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldFetchAJob_FromTheSpecifiedQueue() + { + // Arrange + UseConnection(connection => + { + var jobQueue = new JobQueueDto + { + JobId = 1, + Queue = "default" + }; + connection.JobQueue.Insert(jobQueue); + + var id = jobQueue.Id; + var queue = CreateJobQueue(connection); + + // Act + MongoFetchedJob payload = (MongoFetchedJob)queue.Dequeue(DefaultQueues, CreateTimingOutCancellationToken()); + + // Assert + Assert.Equal(id, payload.Id); + Assert.Equal("1", payload.JobId); + Assert.Equal("default", payload.Queue); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldLeaveJobInTheQueue_ButSetItsFetchedAtValue() + { + // Arrange + UseConnection(connection => + { + var job = new JobDto + { + InvocationData = "", + Arguments = "", + CreatedAt = connection.GetServerTimeUtc() + }; + connection.Job.Insert(job); + + var jobQueue = new JobQueueDto + { + JobId = job.Id, + Queue = "default" + }; + connection.JobQueue.Insert(jobQueue); + + var queue = CreateJobQueue(connection); + + // Act + var payload = queue.Dequeue(DefaultQueues, CreateTimingOutCancellationToken()); + + // Assert + Assert.NotNull(payload); + + var fetchedAt = connection.JobQueue.FindOne(Query.EQ(_ => _.JobId, int.Parse(payload.JobId))).FetchedAt; + + Assert.NotNull(fetchedAt); + Assert.True(fetchedAt > DateTime.UtcNow.AddMinutes(-1)); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldFetchATimedOutJobs_FromTheSpecifiedQueue() + { + // Arrange + UseConnection(connection => + { + var job = new JobDto + { + InvocationData = "", + Arguments = "", + CreatedAt = connection.GetServerTimeUtc() + }; + connection.Job.Insert(job); + + var jobQueue = new JobQueueDto + { + JobId = job.Id, + Queue = "default", + FetchedAt = connection.GetServerTimeUtc().AddDays(-1) + }; + connection.JobQueue.Insert(jobQueue); + + var queue = CreateJobQueue(connection); + + // Act + var payload = queue.Dequeue(DefaultQueues, CreateTimingOutCancellationToken()); + + // Assert + Assert.NotEmpty(payload.JobId); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldSetFetchedAt_OnlyForTheFetchedJob() + { + // Arrange + UseConnection(connection => + { + var job1 = new JobDto + { + InvocationData = "", + Arguments = "", + CreatedAt = connection.GetServerTimeUtc() + }; + connection.Job.Insert(job1); + + var job2 = new JobDto + { + InvocationData = "", + Arguments = "", + CreatedAt = connection.GetServerTimeUtc() + }; + connection.Job.Insert(job2); + + connection.JobQueue.Insert(new JobQueueDto + { + JobId = job1.Id, + Queue = "default" + }); + + connection.JobQueue.Insert(new JobQueueDto + { + JobId = job2.Id, + Queue = "default" + }); + + var queue = CreateJobQueue(connection); + + // Act + var payload = queue.Dequeue(DefaultQueues, CreateTimingOutCancellationToken()); + + // Assert + var otherJobFetchedAt = connection.JobQueue.FindOne(Query.NE(_ => _.JobId, int.Parse(payload.JobId))).FetchedAt; + + Assert.Null(otherJobFetchedAt); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldFetchJobs_OnlyFromSpecifiedQueues() + { + UseConnection(connection => + { + var job1 = new JobDto + { + InvocationData = "", + Arguments = "", + CreatedAt = connection.GetServerTimeUtc() + }; + connection.Job.Insert(job1); + + connection.JobQueue.Insert(new JobQueueDto + { + JobId = job1.Id, + Queue = "critical" + }); + + + var queue = CreateJobQueue(connection); + + Assert.Throws(() => queue.Dequeue(DefaultQueues, CreateTimingOutCancellationToken())); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldFetchJobs_FromMultipleQueues() + { + UseConnection(connection => + { + var job1 = new JobDto + { + InvocationData = "", + Arguments = "", + CreatedAt = connection.GetServerTimeUtc() + }; + connection.Job.Insert(job1); + + var job2 = new JobDto + { + InvocationData = "", + Arguments = "", + CreatedAt = connection.GetServerTimeUtc() + }; + connection.Job.Insert(job2); + + connection.JobQueue.Insert(new JobQueueDto + { + JobId = job1.Id, + Queue = "critical" + }); + + connection.JobQueue.Insert(new JobQueueDto + { + JobId = job2.Id, + Queue = "default" + }); + + var queue = CreateJobQueue(connection); + + var critical = (MongoFetchedJob)queue.Dequeue( + new[] { "critical", "default" }, + CreateTimingOutCancellationToken()); + + Assert.NotNull(critical.JobId); + Assert.Equal("critical", critical.Queue); + + var @default = (MongoFetchedJob)queue.Dequeue( + new[] { "critical", "default" }, + CreateTimingOutCancellationToken()); + + Assert.NotNull(@default.JobId); + Assert.Equal("default", @default.Queue); + }); + } + + [Fact, CleanDatabase] + public void Enqueue_AddsAJobToTheQueue() + { + UseConnection(connection => + { + var queue = CreateJobQueue(connection); + + queue.Enqueue("default", "1"); + + var record = connection.JobQueue.FindAll().Single(); + Assert.Equal("1", record.JobId.ToString()); + Assert.Equal("default", record.Queue); + Assert.Null(record.FetchedAt); + }); + } + + private static CancellationToken CreateTimingOutCancellationToken() + { + var source = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + return source.Token; + } + + private static MongoJobQueue CreateJobQueue(HangfireDbContext connection) + { + return new MongoJobQueue(connection, new MongoStorageOptions()); + } + + private static void UseConnection(Action action) + { + using (var connection = ConnectionUtils.CreateConnection()) + { + action(connection); + } + } + } +} \ No newline at end of file diff --git a/tests/Hangfire.Mongo.Tests/MongoStorageFacts.cs b/tests/Hangfire.Mongo.Tests/MongoStorageFacts.cs new file mode 100644 index 0000000..46fc6f6 --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/MongoStorageFacts.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Hangfire.Mongo.Tests.Utils; +using Hangfire.Server; +using Hangfire.Storage; +using Xunit; + +namespace Hangfire.Mongo.Tests +{ + public class MongoStorageFacts + { + [Fact] + public void Ctor_ThrowsAnException_WhenConnectionStringIsNull() + { + var exception = Assert.Throws(() => new MongoStorage(null, "database")); + + Assert.Equal("connectionString", exception.ParamName); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenDatabaseNameIsNull() + { + var exception = Assert.Throws(() => new MongoStorage("localhost", null)); + + Assert.Equal("databaseName", exception.ParamName); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenOptionsValueIsNull() + { + var exception = Assert.Throws(() => new MongoStorage("localhost", "database", null)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact, CleanDatabase] + public void GetMonitoringApi_ReturnsNonNullInstance() + { + MongoStorage storage = CreateStorage(); + IMonitoringApi api = storage.GetMonitoringApi(); + Assert.NotNull(api); + } + + [Fact, CleanDatabase] + public void GetConnection_ReturnsNonNullInstance() + { + MongoStorage storage = CreateStorage(); + using (IStorageConnection connection = storage.GetConnection()) + { + Assert.NotNull(connection); + } + } + + [Fact] + public void GetComponents_ReturnsAllNeededComponents() + { + MongoStorage storage = CreateStorage(); + + IEnumerable components = storage.GetComponents(); + + Type[] componentTypes = components.Select(x => x.GetType()).ToArray(); + Assert.Contains(typeof(ExpirationManager), componentTypes); + } + + private static MongoStorage CreateStorage() + { + return new MongoStorage(ConnectionUtils.GetConnectionString(), ConnectionUtils.GetDatabaseName()); + } + } +} \ No newline at end of file diff --git a/tests/Hangfire.Mongo.Tests/MongoStorageOptionsFacts.cs b/tests/Hangfire.Mongo.Tests/MongoStorageOptionsFacts.cs new file mode 100644 index 0000000..1aa7ae6 --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/MongoStorageOptionsFacts.cs @@ -0,0 +1,63 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Runtime.Remoting; +using Xunit; + +namespace Hangfire.Mongo.Tests +{ + public class MongoStorageOptionsFacts + { + [Fact] + public void Ctor_SetsTheDefaultOptions() + { + MongoStorageOptions options = new MongoStorageOptions(); + + Assert.Equal("hangfire", options.Prefix); + Assert.True(options.InvisibilityTimeout > TimeSpan.Zero); + } + + [Fact] + public void Ctor_SetsTheDefaultOptions_ShouldGenerateClientId() + { + var options = new MongoStorageOptions(); + Assert.False(String.IsNullOrWhiteSpace(options.ClientId)); + } + + [Fact] + public void Ctor_SetsTheDefaultOptions_ShouldGenerateUniqueClientId() + { + var options1 = new MongoStorageOptions(); + var options2 = new MongoStorageOptions(); + var options3 = new MongoStorageOptions(); + + IEnumerable result = new[] {options1.ClientId, options2.ClientId, options3.ClientId}.Distinct(); + + Assert.Equal(3, result.Count()); + } + + [Fact] + public void Set_QueuePollInterval_ShouldThrowAnException_WhenGivenIntervalIsEqualToZero() + { + var options = new MongoStorageOptions(); + Assert.Throws( + () => options.QueuePollInterval = TimeSpan.Zero); + } + + [Fact] + public void Set_QueuePollInterval_ShouldThrowAnException_WhenGivenIntervalIsNegative() + { + var options = new MongoStorageOptions(); + Assert.Throws( + () => options.QueuePollInterval = TimeSpan.FromSeconds(-1)); + } + + [Fact] + public void Set_QueuePollInterval_SetsTheValue() + { + var options = new MongoStorageOptions(); + options.QueuePollInterval = TimeSpan.FromSeconds(1); + Assert.Equal(TimeSpan.FromSeconds(1), options.QueuePollInterval); + } + } +} \ No newline at end of file diff --git a/tests/Hangfire.Mongo.Tests/MongoWriteOnlyTransactionFacts.cs b/tests/Hangfire.Mongo.Tests/MongoWriteOnlyTransactionFacts.cs new file mode 100644 index 0000000..cb5b333 --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/MongoWriteOnlyTransactionFacts.cs @@ -0,0 +1,735 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Diagnostics; +using System.Linq; +using Hangfire.Mongo.Database; +using Hangfire.Mongo.Dto; +using Hangfire.Mongo.MongoUtils; +using Hangfire.Mongo.PersistentJobQueue; +using Hangfire.Mongo.Tests.Utils; +using Hangfire.States; +using MongoDB.Bson; +using MongoDB.Driver.Builders; +using Moq; +using Xunit; + +namespace Hangfire.Mongo.Tests +{ + public class MongoWriteOnlyTransactionFacts + { + private readonly PersistentJobQueueProviderCollection _queueProviders; + + public MongoWriteOnlyTransactionFacts() + { + Mock defaultProvider = new Mock(); + defaultProvider.Setup(x => x.GetJobQueue(It.IsNotNull())) + .Returns(new Mock().Object); + + _queueProviders = new PersistentJobQueueProviderCollection(defaultProvider.Object); + } + + [Fact] + public void Ctor_ThrowsAnException_IfConnectionIsNull() + { + ArgumentNullException exception = Assert.Throws(() => new MongoWriteOnlyTransaction(null, _queueProviders)); + + Assert.Equal("connection", exception.ParamName); + } + + [Fact, CleanDatabase] + public void Ctor_ThrowsAnException_IfProvidersCollectionIsNull() + { + var exception = Assert.Throws(() => new MongoWriteOnlyTransaction(ConnectionUtils.CreateConnection(), null)); + + Assert.Equal("queueProviders", exception.ParamName); + } + + [Fact, CleanDatabase] + public void ExpireJob_SetsJobExpirationData() + { + UseConnection(database => + { + JobDto job = new JobDto + { + Id = 1, + InvocationData = "", + Arguments = "", + CreatedAt = database.GetServerTimeUtc() + }; + database.Job.Insert(job); + + JobDto anotherJob = new JobDto + { + Id = 2, + InvocationData = "", + Arguments = "", + CreatedAt = database.GetServerTimeUtc() + }; + database.Job.Insert(anotherJob); + + var jobId = job.Id; + var anotherJobId = anotherJob.Id; + + Commit(database, x => x.ExpireJob(jobId.ToString(), TimeSpan.FromDays(1))); + + var testJob = GetTestJob(database, jobId); + Assert.True(database.GetServerTimeUtc().AddMinutes(-1) < testJob.ExpireAt && testJob.ExpireAt <= database.GetServerTimeUtc().AddDays(1)); + + var anotherTestJob = GetTestJob(database, anotherJobId); + Assert.Null(anotherTestJob.ExpireAt); + }); + } + + [Fact, CleanDatabase] + public void PersistJob_ClearsTheJobExpirationData() + { + UseConnection(database => + { + JobDto job = new JobDto + { + Id = 1, + InvocationData = "", + Arguments = "", + CreatedAt = database.GetServerTimeUtc(), + ExpireAt = database.GetServerTimeUtc() + }; + database.Job.Insert(job); + + JobDto anotherJob = new JobDto + { + Id = 2, + InvocationData = "", + Arguments = "", + CreatedAt = database.GetServerTimeUtc(), + ExpireAt = database.GetServerTimeUtc() + }; + database.Job.Insert(anotherJob); + + var jobId = job.Id; + var anotherJobId = anotherJob.Id; + + Commit(database, x => x.PersistJob(jobId.ToString())); + + var testjob = GetTestJob(database, jobId); + Assert.Null(testjob.ExpireAt); + + var anotherTestJob = GetTestJob(database, anotherJobId); + Assert.NotNull(anotherTestJob.ExpireAt); + }); + } + + [Fact, CleanDatabase] + public void SetJobState_AppendsAStateAndSetItToTheJob() + { + UseConnection(database => + { + JobDto job = new JobDto + { + Id = 1, + InvocationData = "", + Arguments = "", + CreatedAt = database.GetServerTimeUtc() + }; + database.Job.Insert(job); + + JobDto anotherJob = new JobDto + { + Id = 2, + InvocationData = "", + Arguments = "", + CreatedAt = database.GetServerTimeUtc() + }; + database.Job.Insert(anotherJob); + + var jobId = job.Id; + var anotherJobId = anotherJob.Id; + + var state = new Mock(); + state.Setup(x => x.Name).Returns("State"); + state.Setup(x => x.Reason).Returns("Reason"); + state.Setup(x => x.SerializeData()) + .Returns(new Dictionary { { "Name", "Value" } }); + + Commit(database, x => x.SetJobState(jobId.ToString(), state.Object)); + + var testJob = GetTestJob(database, jobId); + Assert.Equal("State", testJob.StateName); + Assert.NotNull(testJob.StateId); + + var anotherTestJob = GetTestJob(database, anotherJobId); + Assert.Null(anotherTestJob.StateName); + Assert.Equal(ObjectId.Empty, anotherTestJob.StateId); + + StateDto jobState = database.State.FindAll().Single(); + Assert.Equal(jobId, jobState.JobId); + Assert.Equal("State", jobState.Name); + Assert.Equal("Reason", jobState.Reason); + Assert.NotNull(jobState.CreatedAt); + Assert.Equal("{\"Name\":\"Value\"}", jobState.Data); + }); + } + + [Fact, CleanDatabase] + public void AddJobState_JustAddsANewRecordInATable() + { + UseConnection(database => + { + JobDto job = new JobDto + { + Id = 1, + InvocationData = "", + Arguments = "", + CreatedAt = database.GetServerTimeUtc() + }; + database.Job.Insert(job); + + var jobId = job.Id; + + var state = new Mock(); + state.Setup(x => x.Name).Returns("State"); + state.Setup(x => x.Reason).Returns("Reason"); + state.Setup(x => x.SerializeData()) + .Returns(new Dictionary { { "Name", "Value" } }); + + Commit(database, x => x.AddJobState(jobId.ToString(), state.Object)); + + var testJob = GetTestJob(database, jobId); + Assert.Null(testJob.StateName); + Assert.Equal(ObjectId.Empty, testJob.StateId); + + StateDto jobState = database.State.FindAll().Single(); + Assert.Equal(jobId, jobState.JobId); + Assert.Equal("State", jobState.Name); + Assert.Equal("Reason", jobState.Reason); + Assert.NotNull(jobState.CreatedAt); + Assert.Equal("{\"Name\":\"Value\"}", jobState.Data); + }); + } + + [Fact, CleanDatabase] + public void AddToQueue_CallsEnqueue_OnTargetPersistentQueue() + { + UseConnection(database => + { + var correctJobQueue = new Mock(); + var correctProvider = new Mock(); + correctProvider.Setup(x => x.GetJobQueue(It.IsNotNull())) + .Returns(correctJobQueue.Object); + + _queueProviders.Add(correctProvider.Object, new[] { "default" }); + + Commit(database, x => x.AddToQueue("default", "1")); + + correctJobQueue.Verify(x => x.Enqueue("default", "1")); + }); + } + + [Fact, CleanDatabase] + public void IncrementCounter_AddsRecordToCounterTable_WithPositiveValue() + { + UseConnection(database => + { + Commit(database, x => x.IncrementCounter("my-key")); + + CounterDto record = database.Counter.FindAll().Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal(1, record.Value); + Assert.Equal(null, record.ExpireAt); + }); + } + + [Fact, CleanDatabase] + public void IncrementCounter_WithExpiry_AddsARecord_WithExpirationTimeSet() + { + UseConnection(database => + { + Commit(database, x => x.IncrementCounter("my-key", TimeSpan.FromDays(1))); + + CounterDto record = database.Counter.FindAll().Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal(1, record.Value); + Assert.NotNull(record.ExpireAt); + + var expireAt = (DateTime)record.ExpireAt; + + Assert.True(database.GetServerTimeUtc().AddHours(23) < expireAt); + Assert.True(expireAt < database.GetServerTimeUtc().AddHours(25)); + }); + } + + [Fact, CleanDatabase] + public void IncrementCounter_WithExistingKey_AddsAnotherRecord() + { + UseConnection(database => + { + Commit(database, x => + { + x.IncrementCounter("my-key"); + x.IncrementCounter("my-key"); + }); + + var recordCount = database.Counter.Count(); + + Assert.Equal(2, recordCount); + }); + } + + [Fact, CleanDatabase] + public void DecrementCounter_AddsRecordToCounterTable_WithNegativeValue() + { + UseConnection(database => + { + Commit(database, x => x.DecrementCounter("my-key")); + + CounterDto record = database.Counter.FindAll().Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal(-1, record.Value); + Assert.Equal(null, record.ExpireAt); + }); + } + + [Fact, CleanDatabase] + public void DecrementCounter_WithExpiry_AddsARecord_WithExpirationTimeSet() + { + UseConnection(database => + { + Commit(database, x => x.DecrementCounter("my-key", TimeSpan.FromDays(1))); + + CounterDto record = database.Counter.FindAll().Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal(-1, record.Value); + Assert.NotNull(record.ExpireAt); + + var expireAt = (DateTime)record.ExpireAt; + + Assert.True(database.GetServerTimeUtc().AddHours(23) < expireAt); + Assert.True(expireAt < database.GetServerTimeUtc().AddHours(25)); + }); + } + + [Fact, CleanDatabase] + public void DecrementCounter_WithExistingKey_AddsAnotherRecord() + { + UseConnection(database => + { + Commit(database, x => + { + x.DecrementCounter("my-key"); + x.DecrementCounter("my-key"); + }); + + var recordCount = database.Counter.Count(); + + Assert.Equal(2, recordCount); + }); + } + + [Fact, CleanDatabase] + public void AddToSet_AddsARecord_IfThereIsNo_SuchKeyAndValue() + { + UseConnection(database => + { + Commit(database, x => x.AddToSet("my-key", "my-value")); + + var record = database.Set.FindAll().Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal("my-value", record.Value); + Assert.Equal(0.0, record.Score, 2); + }); + } + + [Fact, CleanDatabase] + public void AddToSet_AddsARecord_WhenKeyIsExists_ButValuesAreDifferent() + { + UseConnection(database => + { + Commit(database, x => + { + x.AddToSet("my-key", "my-value"); + x.AddToSet("my-key", "another-value"); + }); + + var recordCount = database.Set.Count(); + + Assert.Equal(2, recordCount); + }); + } + + [Fact, CleanDatabase] + public void AddToSet_DoesNotAddARecord_WhenBothKeyAndValueAreExist() + { + UseConnection(database => + { + Commit(database, x => + { + x.AddToSet("my-key", "my-value"); + x.AddToSet("my-key", "my-value"); + }); + + var recordCount = database.Set.Count(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void AddToSet_WithScore_AddsARecordWithScore_WhenBothKeyAndValueAreNotExist() + { + UseConnection(database => + { + Commit(database, x => x.AddToSet("my-key", "my-value", 3.2)); + + var record = database.Set.FindAll().Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal("my-value", record.Value); + Assert.Equal(3.2, record.Score, 3); + }); + } + + [Fact, CleanDatabase] + public void AddToSet_WithScore_UpdatesAScore_WhenBothKeyAndValueAreExist() + { + UseConnection(database => + { + Commit(database, x => + { + x.AddToSet("my-key", "my-value"); + x.AddToSet("my-key", "my-value", 3.2); + }); + + var record = database.Set.FindAll().Single(); + + Assert.Equal(3.2, record.Score, 3); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromSet_RemovesARecord_WithGivenKeyAndValue() + { + UseConnection(database => + { + Commit(database, x => + { + x.AddToSet("my-key", "my-value"); + x.RemoveFromSet("my-key", "my-value"); + }); + + var recordCount = database.Set.Count(); + + Assert.Equal(0, recordCount); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromSet_DoesNotRemoveRecord_WithSameKey_AndDifferentValue() + { + UseConnection(database => + { + Commit(database, x => + { + x.AddToSet("my-key", "my-value"); + x.RemoveFromSet("my-key", "different-value"); + }); + + var recordCount = database.Set.Count(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromSet_DoesNotRemoveRecord_WithSameValue_AndDifferentKey() + { + UseConnection(database => + { + Commit(database, x => + { + x.AddToSet("my-key", "my-value"); + x.RemoveFromSet("different-key", "my-value"); + }); + + var recordCount = database.Set.Count(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void InsertToList_AddsARecord_WithGivenValues() + { + UseConnection(database => + { + Commit(database, x => x.InsertToList("my-key", "my-value")); + + var record = database.List.FindAll().Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal("my-value", record.Value); + }); + } + + [Fact, CleanDatabase] + public void InsertToList_AddsAnotherRecord_WhenBothKeyAndValueAreExist() + { + UseConnection(database => + { + Commit(database, x => + { + x.InsertToList("my-key", "my-value"); + x.InsertToList("my-key", "my-value"); + }); + + var recordCount = database.List.Count(); + + Assert.Equal(2, recordCount); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromList_RemovesAllRecords_WithGivenKeyAndValue() + { + UseConnection(database => + { + Commit(database, x => + { + x.InsertToList("my-key", "my-value"); + x.InsertToList("my-key", "my-value"); + x.RemoveFromList("my-key", "my-value"); + }); + + var recordCount = database.List.Count(); + + Assert.Equal(0, recordCount); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromList_DoesNotRemoveRecords_WithSameKey_ButDifferentValue() + { + UseConnection(database => + { + Commit(database, x => + { + x.InsertToList("my-key", "my-value"); + x.RemoveFromList("my-key", "different-value"); + }); + + var recordCount = database.List.Count(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromList_DoesNotRemoveRecords_WithSameValue_ButDifferentKey() + { + UseConnection(database => + { + Commit(database, x => + { + x.InsertToList("my-key", "my-value"); + x.RemoveFromList("different-key", "my-value"); + }); + + var recordCount = database.List.Count(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void TrimList_TrimsAList_ToASpecifiedRange() + { + UseConnection(database => + { + Commit(database, x => + { + x.InsertToList("my-key", "0"); + x.InsertToList("my-key", "1"); + x.InsertToList("my-key", "2"); + x.InsertToList("my-key", "3"); + x.TrimList("my-key", 1, 2); + }); + + var records = database.List.FindAll().ToArray(); + + Assert.Equal(2, records.Length); + Assert.Equal("1", records[0].Value); + Assert.Equal("2", records[1].Value); + }); + } + + [Fact, CleanDatabase] + public void TrimList_RemovesRecordsToEnd_IfKeepAndingAt_GreaterThanMaxElementIndex() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.InsertToList("my-key", "0"); + x.InsertToList("my-key", "1"); + x.InsertToList("my-key", "2"); + x.TrimList("my-key", 1, 100); + }); + + var recordCount = sql.List.Count(); + + Assert.Equal(2, recordCount); + }); + } + + [Fact, CleanDatabase] + public void TrimList_RemovesAllRecords_WhenStartingFromValue_GreaterThanMaxElementIndex() + { + UseConnection(database => + { + Commit(database, x => + { + x.InsertToList("my-key", "0"); + x.TrimList("my-key", 1, 100); + }); + + var recordCount = database.List.Count(); + + Assert.Equal(0, recordCount); + }); + } + + [Fact, CleanDatabase] + public void TrimList_RemovesAllRecords_IfStartFromGreaterThanEndingAt() + { + UseConnection(database => + { + Commit(database, x => + { + x.InsertToList("my-key", "0"); + x.TrimList("my-key", 1, 0); + }); + + var recordCount = database.List.Count(); + + Assert.Equal(0, recordCount); + }); + } + + [Fact, CleanDatabase] + public void TrimList_RemovesRecords_OnlyOfAGivenKey() + { + UseConnection(database => + { + Commit(database, x => + { + x.InsertToList("my-key", "0"); + x.TrimList("another-key", 1, 0); + }); + + var recordCount = database.List.Count(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(database => + { + ArgumentNullException exception = Assert.Throws( + () => Commit(database, x => x.SetRangeInHash(null, new Dictionary()))); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_ThrowsAnException_WhenKeyValuePairsArgumentIsNull() + { + UseConnection(database => + { + var exception = Assert.Throws( + () => Commit(database, x => x.SetRangeInHash("some-hash", null))); + + Assert.Equal("keyValuePairs", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_MergesAllRecords() + { + UseConnection(database => + { + Commit(database, x => x.SetRangeInHash("some-hash", new Dictionary + { + { "Key1", "Value1" }, + { "Key2", "Value2" } + })); + + var result = database.Hash.Find(Query.EQ(_ => _.Key, "some-hash")) + .ToDictionary(x => x.Field, x => x.Value); + + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["Key2"]); + }); + } + + [Fact, CleanDatabase] + public void RemoveHash_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(database => + { + Assert.Throws( + () => Commit(database, x => x.RemoveHash(null))); + }); + } + + [Fact, CleanDatabase] + public void RemoveHash_RemovesAllHashRecords() + { + UseConnection(database => + { + // Arrange + Commit(database, x => x.SetRangeInHash("some-hash", new Dictionary + { + { "Key1", "Value1" }, + { "Key2", "Value2" } + })); + + // Act + Commit(database, x => x.RemoveHash("some-hash")); + + // Assert + var count = database.Hash.Count(); + Assert.Equal(0, count); + }); + } + + private static dynamic GetTestJob(HangfireDbContext database, int jobId) + { + return database.Job.FindOneById(jobId); + } + + private void UseConnection(Action action) + { + using (HangfireDbContext connection = ConnectionUtils.CreateConnection()) + { + action(connection); + } + } + + private void Commit(HangfireDbContext connection, Action action) + { + using (MongoWriteOnlyTransaction transaction = new MongoWriteOnlyTransaction(connection, _queueProviders)) + { + action(transaction); + transaction.Commit(); + } + } + } +} \ No newline at end of file diff --git a/tests/Hangfire.Mongo.Tests/Properties/AssemblyInfo.cs b/tests/Hangfire.Mongo.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..23e7210 --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,37 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Hangfire.Mongo.Tests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Hangfire.Mongo.Tests")] +[assembly: AssemblyCopyright("Copyright © 2014 Sergey Zwezdin")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("c9ee6131-b199-40d2-ba5d-bb51da90ba08")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyInformationalVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/tests/Hangfire.Mongo.Tests/Utils/CleanDatabaseAttribute.cs b/tests/Hangfire.Mongo.Tests/Utils/CleanDatabaseAttribute.cs new file mode 100644 index 0000000..a1d60b3 --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/Utils/CleanDatabaseAttribute.cs @@ -0,0 +1,67 @@ +using System; +using System.Data.SqlClient; +using System.Reflection; +using System.Threading; +using Hangfire.Mongo.Database; +using Hangfire.Mongo.Dto; +using MongoDB.Driver; +using Xunit; + +namespace Hangfire.Mongo.Tests.Utils +{ + public class CleanDatabaseAttribute : BeforeAfterTestAttribute + { + private static readonly object GlobalLock = new object(); + + private static bool _sqlObjectInstalled; + + public CleanDatabaseAttribute() + { + } + + public override void Before(MethodInfo methodUnderTest) + { + Monitor.Enter(GlobalLock); + + if (_sqlObjectInstalled == false) + { + RecreateDatabaseAndInstallObjects(); + _sqlObjectInstalled = true; + } + } + + public override void After(MethodInfo methodUnderTest) + { + Monitor.Exit(GlobalLock); + } + + private static void RecreateDatabaseAndInstallObjects() + { + using (HangfireDbContext context = new HangfireDbContext(ConnectionUtils.GetConnectionString(), ConnectionUtils.GetDatabaseName())) + { + context.Init(); + + MongoCollection[] collections = + { + context.DistributedLock, + context.Counter, + context.Hash, + context.Job, + context.JobParameter, + context.JobQueue, + context.List, + context.Server, + context.Set, + context.State + }; + + foreach (MongoCollection collection in collections) + { + WriteConcernResult result = collection.RemoveAll(); + if (result.Ok == false) + throw new InvalidOperationException("Unable to cleanup database."); + } + } + } + } +} diff --git a/tests/Hangfire.Mongo.Tests/Utils/ConnectionUtils.cs b/tests/Hangfire.Mongo.Tests/Utils/ConnectionUtils.cs new file mode 100644 index 0000000..c5b210a --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/Utils/ConnectionUtils.cs @@ -0,0 +1,35 @@ +using System; +using Hangfire.Mongo.Database; + +namespace Hangfire.Mongo.Tests.Utils +{ + public class ConnectionUtils + { + private const string DatabaseVariable = "Hangfire_Mongo_DatabaseName"; + private const string ConnectionStringTemplateVariable = "Hangfire_Mongo_ConnectionStringTemplate"; + + private const string DefaultDatabaseName = @"Hangfire-Mongo-Tests"; + private const string DefaultConnectionStringTemplate = @"mongodb://localhost"; + + public static string GetDatabaseName() + { + return Environment.GetEnvironmentVariable(DatabaseVariable) ?? DefaultDatabaseName; + } + + public static string GetConnectionString() + { + return String.Format(GetConnectionStringTemplate(), GetDatabaseName()); + } + + private static string GetConnectionStringTemplate() + { + return Environment.GetEnvironmentVariable(ConnectionStringTemplateVariable) ?? DefaultConnectionStringTemplate; + } + + public static HangfireDbContext CreateConnection() + { + HangfireDbContext connection = new HangfireDbContext(GetConnectionString(), GetDatabaseName()); + return connection; + } + } +} \ No newline at end of file diff --git a/tests/Hangfire.Mongo.Tests/packages.config b/tests/Hangfire.Mongo.Tests/packages.config new file mode 100644 index 0000000..bed54b9 --- /dev/null +++ b/tests/Hangfire.Mongo.Tests/packages.config @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/tests/Hangfire.SqlServer.Tests/CountersAggregatorFacts.cs b/tests/Hangfire.SqlServer.Tests/CountersAggregatorFacts.cs new file mode 100644 index 0000000..9885a77 --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/CountersAggregatorFacts.cs @@ -0,0 +1,47 @@ +using System; +using System.Data.SqlClient; +using System.Linq; +using System.Threading; +using Dapper; +using Xunit; + +namespace Hangfire.SqlServer.Tests +{ + public class CountersAggregatorFacts + { + [Fact, CleanDatabase] + public void CountersAggregatorExecutesProperly() + { + const string createSql = @" +insert into HangFire.Counter ([Key], [Value], ExpireAt) +values ('key', 1, @expireAt)"; + + using (var connection = CreateConnection()) + { + // Arrange + connection.Execute(createSql, new { expireAt = DateTime.UtcNow.AddHours(1) }); + + var aggregator = CreateAggregator(connection); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act + aggregator.Execute(cts.Token); + + // Assert + Assert.Equal(1, connection.Query(@"select count(*) from HangFire.AggregatedCounter").Single()); + } + } + + private static SqlConnection CreateConnection() + { + return ConnectionUtils.CreateConnection(); + } + + private static CountersAggregator CreateAggregator(SqlConnection connection) + { + var storage = new SqlServerStorage(connection); + return new CountersAggregator(storage, TimeSpan.Zero); + } + } +} \ No newline at end of file diff --git a/tests/Hangfire.SqlServer.Tests/ExpirationManagerFacts.cs b/tests/Hangfire.SqlServer.Tests/ExpirationManagerFacts.cs new file mode 100644 index 0000000..c8455b9 --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/ExpirationManagerFacts.cs @@ -0,0 +1,204 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using System.Linq; +using System.Threading; +using Dapper; +using Xunit; + +namespace Hangfire.SqlServer.Tests +{ + public class ExpirationManagerFacts + { + private readonly CancellationToken _token; + + public ExpirationManagerFacts() + { + var cts = new CancellationTokenSource(); + _token = cts.Token; + } + + [Fact] + public void Ctor_ThrowsAnException_WhenStorageIsNull() + { + Assert.Throws(() => new ExpirationManager(null)); + } + + [Fact, CleanDatabase] + public void Execute_RemovesOutdatedRecords() + { + using (var connection = CreateConnection()) + { + var entryId = CreateExpirationEntry(connection, DateTime.UtcNow.AddMonths(-1)); + var manager = CreateManager(connection); + + manager.Execute(_token); + + Assert.True(IsEntryExpired(connection, entryId)); + } + } + + [Fact, CleanDatabase] + public void Execute_DoesNotRemoveEntries_WithNoExpirationTimeSet() + { + using (var connection = CreateConnection()) + { + var entryId = CreateExpirationEntry(connection, null); + var manager = CreateManager(connection); + + manager.Execute(_token); + + Assert.False(IsEntryExpired(connection, entryId)); + } + } + + [Fact, CleanDatabase] + public void Execute_DoesNotRemoveEntries_WithFreshExpirationTime() + { + using (var connection = CreateConnection()) + { + var entryId = CreateExpirationEntry(connection, DateTime.Now.AddMonths(1)); + var manager = CreateManager(connection); + + manager.Execute(_token); + + Assert.False(IsEntryExpired(connection, entryId)); + } + } + + [Fact, CleanDatabase] + public void Execute_Processes_AggregatedCounterTable() + { + using (var connection = CreateConnection()) + { + // Arrange + const string createSql = @" +insert into HangFire.AggregatedCounter ([Key], [Value], ExpireAt) +values ('key', 1, @expireAt)"; + connection.Execute(createSql, new { expireAt = DateTime.UtcNow.AddMonths(-1) }); + + var manager = CreateManager(connection); + + // Act + manager.Execute(_token); + + // Assert + Assert.Equal(0, connection.Query(@"select count(*) from HangFire.Counter").Single()); + } + } + + [Fact, CleanDatabase] + public void Execute_Processes_JobTable() + { + using (var connection = CreateConnection()) + { + // Arrange + const string createSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt, ExpireAt) +values ('', '', getutcdate(), @expireAt)"; + connection.Execute(createSql, new { expireAt = DateTime.UtcNow.AddMonths(-1) }); + + var manager = CreateManager(connection); + + // Act + manager.Execute(_token); + + // Assert + Assert.Equal(0, connection.Query(@"select count(*) from HangFire.Job").Single()); + } + } + + [Fact, CleanDatabase] + public void Execute_Processes_ListTable() + { + using (var connection = CreateConnection()) + { + // Arrange + const string createSql = @" +insert into HangFire.List ([Key], ExpireAt) +values ('key', @expireAt)"; + connection.Execute(createSql, new { expireAt = DateTime.UtcNow.AddMonths(-1) }); + + var manager = CreateManager(connection); + + // Act + manager.Execute(_token); + + // Assert + Assert.Equal(0, connection.Query(@"select count(*) from HangFire.List").Single()); + } + } + + [Fact, CleanDatabase] + public void Execute_Processes_SetTable() + { + using (var connection = CreateConnection()) + { + // Arrange + const string createSql = @" +insert into HangFire.[Set] ([Key], [Score], [Value], ExpireAt) +values ('key', 0, '', @expireAt)"; + connection.Execute(createSql, new { expireAt = DateTime.UtcNow.AddMonths(-1) }); + + var manager = CreateManager(connection); + + // Act + manager.Execute(_token); + + // Assert + Assert.Equal(0, connection.Query(@"select count(*) from HangFire.[Set]").Single()); + } + } + + [Fact, CleanDatabase] + public void Execute_Processes_HashTable() + { + using (var connection = CreateConnection()) + { + // Arrange + const string createSql = @" +insert into HangFire.Hash ([Key], [Field], [Value], ExpireAt) +values ('key', 'field', '', @expireAt)"; + connection.Execute(createSql, new { expireAt = DateTime.UtcNow.AddMonths(-1) }); + + var manager = CreateManager(connection); + + // Act + manager.Execute(_token); + + // Assert + Assert.Equal(0, connection.Query(@"select count(*) from HangFire.Hash").Single()); + } + } + + private static int CreateExpirationEntry(SqlConnection connection, DateTime? expireAt) + { + const string insertSql = @" +insert into HangFire.AggregatedCounter ([Key], [Value], [ExpireAt]) +values ('key', 1, @expireAt) +select scope_identity() as Id"; + + var id = connection.Query(insertSql, new { @expireAt = expireAt }).Single(); + var recordId = (int) id.Id; + return recordId; + } + + private static bool IsEntryExpired(SqlConnection connection, int entryId) + { + var count = connection.Query( + "select count(*) from HangFire.AggregatedCounter where Id = @id", new { id = entryId }).Single(); + return count == 0; + } + + private SqlConnection CreateConnection() + { + return ConnectionUtils.CreateConnection(); + } + + private ExpirationManager CreateManager(SqlConnection connection) + { + var storage = new SqlServerStorage(connection); + return new ExpirationManager(storage, TimeSpan.Zero); + } + } +} diff --git a/tests/Hangfire.SqlServer.Tests/Hangfire.Raven.Tests.csproj b/tests/Hangfire.SqlServer.Tests/Hangfire.Raven.Tests.csproj new file mode 100644 index 0000000..c488a7f --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/Hangfire.Raven.Tests.csproj @@ -0,0 +1,97 @@ + + + + + + Debug + AnyCPU + {6DFFA275-C483-4501-823A-741AC9EC0846} + Library + Properties + Hangfire.SqlServer.Tests + Hangfire.SqlServer.Tests + v4.5 + 512 + ..\..\ + c576183e + + + true + full + false + bin\Debug\ + DEBUG;TRACE + prompt + 4 + + + pdbonly + true + bin\Release\ + TRACE + prompt + 4 + + + + ..\..\packages\Dapper.1.42\lib\net45\Dapper.dll + True + + + ..\..\packages\Moq.4.2.1402.2112\lib\net40\Moq.dll + + + + + + + + + + + + ..\..\packages\xunit.1.9.2\lib\net20\xunit.dll + + + + + + + + + + + + + + + + + + + + + + + {a523c0e3-097d-4869-977f-15a717ea3e83} + Hangfire.Raven + + + + + + + + + This project references NuGet package(s) that are missing on this computer. Enable NuGet Package Restore to download them. For more information, see http://go.microsoft.com/fwlink/?LinkID=322105. The missing file is {0}. + + + + + \ No newline at end of file diff --git a/tests/Hangfire.SqlServer.Tests/Hangfire.SqlServer.Tests.csproj.DotSettings b/tests/Hangfire.SqlServer.Tests/Hangfire.SqlServer.Tests.csproj.DotSettings new file mode 100644 index 0000000..4289bde --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/Hangfire.SqlServer.Tests.csproj.DotSettings @@ -0,0 +1,2 @@ + + True \ No newline at end of file diff --git a/tests/Hangfire.SqlServer.Tests/PersistentJobQueueProviderCollectionFacts.cs b/tests/Hangfire.SqlServer.Tests/PersistentJobQueueProviderCollectionFacts.cs new file mode 100644 index 0000000..cb08d88 --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/PersistentJobQueueProviderCollectionFacts.cs @@ -0,0 +1,98 @@ +using System; +using System.Linq; +using Moq; +using Xunit; + +namespace Hangfire.SqlServer.Tests +{ + public class PersistentJobQueueProviderCollectionFacts + { + private static readonly string[] _queues = { "default", "critical" }; + private readonly Mock _defaultProvider; + private readonly Mock _provider; + + public PersistentJobQueueProviderCollectionFacts() + { + _defaultProvider = new Mock(); + _provider = new Mock(); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenDefaultProviderIsNull() + { + Assert.Throws( + () => new PersistentJobQueueProviderCollection(null)); + } + + [Fact] + public void Enumeration_IncludesTheDefaultProvider() + { + var collection = CreateCollection(); + + var result = collection.ToArray(); + + Assert.Equal(1, result.Length); + Assert.Same(_defaultProvider.Object, result[0]); + } + + [Fact] + public void GetProvider_ReturnsTheDefaultProvider_WhenProviderCanNotBeResolvedByQueue() + { + var collection = CreateCollection(); + + var provider = collection.GetProvider("queue"); + + Assert.Same(_defaultProvider.Object, provider); + } + + [Fact] + public void Add_ThrowsAnException_WhenProviderIsNull() + { + var collection = CreateCollection(); + + var exception = Assert.Throws( + () => collection.Add(null, _queues)); + + Assert.Equal("provider", exception.ParamName); + } + + [Fact] + public void Add_ThrowsAnException_WhenQueuesCollectionIsNull() + { + var collection = CreateCollection(); + + var exception = Assert.Throws( + () => collection.Add(_provider.Object, null)); + + Assert.Equal("queues", exception.ParamName); + } + + [Fact] + public void Enumeration_ContainsAddedProvider() + { + var collection = CreateCollection(); + + collection.Add(_provider.Object, _queues); + + Assert.Contains(_provider.Object, collection); + } + + [Fact] + public void GetProvider_CanBeResolved_ByAnyQueue() + { + var collection = CreateCollection(); + collection.Add(_provider.Object, _queues); + + var provider1 = collection.GetProvider("default"); + var provider2 = collection.GetProvider("critical"); + + Assert.NotSame(_defaultProvider.Object, provider1); + Assert.Same(provider1, provider2); + } + + private PersistentJobQueueProviderCollection CreateCollection() + { + return new PersistentJobQueueProviderCollection(_defaultProvider.Object); + } + } +} diff --git a/tests/Hangfire.SqlServer.Tests/Properties/AssemblyInfo.cs b/tests/Hangfire.SqlServer.Tests/Properties/AssemblyInfo.cs new file mode 100644 index 0000000..f8b47e0 --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/Properties/AssemblyInfo.cs @@ -0,0 +1,36 @@ +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Runtime.InteropServices; + +// General Information about an assembly is controlled through the following +// set of attributes. Change these attribute values to modify the information +// associated with an assembly. +[assembly: AssemblyTitle("Hangfire.SqlServer.Tests")] +[assembly: AssemblyDescription("")] +[assembly: AssemblyConfiguration("")] +[assembly: AssemblyCompany("")] +[assembly: AssemblyProduct("Hangfire.SqlServer.Tests")] +[assembly: AssemblyCopyright("Copyright © 2014")] +[assembly: AssemblyTrademark("")] +[assembly: AssemblyCulture("")] + +// Setting ComVisible to false makes the types in this assembly not visible +// to COM components. If you need to access a type in this assembly from +// COM, set the ComVisible attribute to true on that type. +[assembly: ComVisible(false)] + +// The following GUID is for the ID of the typelib if this project is exposed to COM +[assembly: Guid("d3d9b19a-a9fc-4ff6-a327-aca8359e68ca")] + +// Version information for an assembly consists of the following four values: +// +// Major Version +// Minor Version +// Build Number +// Revision +// +// You can specify all the values or you can default the Build and Revision Numbers +// by using the '*' as shown below: +// [assembly: AssemblyVersion("1.0.*")] +[assembly: AssemblyVersion("1.0.0.0")] +[assembly: AssemblyFileVersion("1.0.0.0")] diff --git a/tests/Hangfire.SqlServer.Tests/SqlServerConnectionFacts.cs b/tests/Hangfire.SqlServer.Tests/SqlServerConnectionFacts.cs new file mode 100644 index 0000000..27b599f --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/SqlServerConnectionFacts.cs @@ -0,0 +1,1359 @@ +using System; +using System.Collections.Generic; +using System.Data.SqlClient; +using System.Linq; +using System.Threading; +using Dapper; +using Hangfire.Common; +using Hangfire.Server; +using Hangfire.Storage; +using Moq; +using Xunit; +using IsolationLevel = System.Transactions.IsolationLevel; + +namespace Hangfire.SqlServer.Tests +{ + public class SqlServerConnectionFacts + { + private readonly Mock _queue; + private readonly PersistentJobQueueProviderCollection _providers; + + public SqlServerConnectionFacts() + { + _queue = new Mock(); + + var provider = new Mock(); + provider.Setup(x => x.GetJobQueue()) + .Returns(_queue.Object); + + _providers = new PersistentJobQueueProviderCollection(provider.Object); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenStorageIsNull() + { + var exception = Assert.Throws( + () => new SqlServerConnection(null)); + + Assert.Equal("storage", exception.ParamName); + } + + [Fact, CleanDatabase] + public void FetchNextJob_DelegatesItsExecution_ToTheQueue() + { + UseConnection(connection => + { + var token = new CancellationToken(); + var queues = new[] { "default" }; + + connection.FetchNextJob(queues, token); + + _queue.Verify(x => x.Dequeue(queues, token)); + }); + } + + [Fact, CleanDatabase] + public void FetchNextJob_Throws_IfMultipleProvidersResolved() + { + UseConnection(connection => + { + var token = new CancellationToken(); + var anotherProvider = new Mock(); + _providers.Add(anotherProvider.Object, new [] { "critical" }); + + Assert.Throws( + () => connection.FetchNextJob(new[] { "critical", "default" }, token)); + }); + } + + [Fact, CleanDatabase] + public void CreateWriteTransaction_ReturnsNonNullInstance() + { + UseConnection(connection => + { + var transaction = connection.CreateWriteTransaction(); + Assert.NotNull(transaction); + }); + } + + [Fact, CleanDatabase] + public void AcquireLock_ReturnsNonNullInstance() + { + UseConnection(connection => + { + var @lock = connection.AcquireDistributedLock("1", TimeSpan.FromSeconds(1)); + Assert.NotNull(@lock); + }); + } + + [Fact, CleanDatabase] + public void CreateExpiredJob_ThrowsAnException_WhenJobIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.CreateExpiredJob( + null, + new Dictionary(), + DateTime.UtcNow, + TimeSpan.Zero)); + + Assert.Equal("job", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void CreateExpiredJob_ThrowsAnException_WhenParametersCollectionIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.CreateExpiredJob( + Job.FromExpression(() => SampleMethod("hello")), + null, + DateTime.UtcNow, + TimeSpan.Zero)); + + Assert.Equal("parameters", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void CreateExpiredJob_CreatesAJobInTheStorage_AndSetsItsParameters() + { + UseConnections((sql, connection) => + { + var createdAt = new DateTime(2012, 12, 12); + var jobId = connection.CreateExpiredJob( + Job.FromExpression(() => SampleMethod("Hello")), + new Dictionary { { "Key1", "Value1" }, { "Key2", "Value2" } }, + createdAt, + TimeSpan.FromDays(1)); + + Assert.NotNull(jobId); + Assert.NotEmpty(jobId); + + var sqlJob = sql.Query("select * from HangFire.Job").Single(); + Assert.Equal(jobId, sqlJob.Id.ToString()); + Assert.Equal(createdAt, sqlJob.CreatedAt); + Assert.Equal(null, (int?) sqlJob.StateId); + Assert.Equal(null, (string) sqlJob.StateName); + + var invocationData = JobHelper.FromJson((string)sqlJob.InvocationData); + invocationData.Arguments = sqlJob.Arguments; + + var job = invocationData.Deserialize(); + Assert.Equal(typeof(SqlServerConnectionFacts), job.Type); + Assert.Equal("SampleMethod", job.Method.Name); + Assert.Equal("\"Hello\"", job.Arguments[0]); + + Assert.True(createdAt.AddDays(1).AddMinutes(-1) < sqlJob.ExpireAt); + Assert.True(sqlJob.ExpireAt < createdAt.AddDays(1).AddMinutes(1)); + + var parameters = sql.Query( + "select * from HangFire.JobParameter where JobId = @id", + new { id = jobId }) + .ToDictionary(x => (string) x.Name, x => (string) x.Value); + + Assert.Equal("Value1", parameters["Key1"]); + Assert.Equal("Value2", parameters["Key2"]); + }); + } + + [Fact, CleanDatabase] + public void GetJobData_ThrowsAnException_WhenJobIdIsNull() + { + UseConnection(connection => Assert.Throws( + () => connection.GetJobData(null))); + } + + [Fact, CleanDatabase] + public void GetJobData_ReturnsNull_WhenThereIsNoSuchJob() + { + UseConnection(connection => + { + var result = connection.GetJobData("1"); + Assert.Null(result); + }); + } + + [Fact, CleanDatabase] + public void GetJobData_ReturnsResult_WhenJobExists() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, StateName, CreatedAt) +values (@invocationData, @arguments, @stateName, getutcdate()) +select scope_identity() as Id"; + + UseConnections((sql, connection) => + { + var job = Job.FromExpression(() => SampleMethod("wrong")); + + var jobId = sql.Query( + arrangeSql, + new + { + invocationData = JobHelper.ToJson(InvocationData.Serialize(job)), + stateName = "Succeeded", + arguments = "['Arguments']" + }).Single(); + + var result = connection.GetJobData(((int)jobId.Id).ToString()); + + Assert.NotNull(result); + Assert.NotNull(result.Job); + Assert.Equal("Succeeded", result.State); + Assert.Equal("Arguments", result.Job.Args[0]); + Assert.Null(result.LoadException); + Assert.True(DateTime.UtcNow.AddMinutes(-1) < result.CreatedAt); + Assert.True(result.CreatedAt < DateTime.UtcNow.AddMinutes(1)); + }); + } + + [Fact, CleanDatabase] + public void GetStateData_ThrowsAnException_WhenJobIdIsNull() + { + UseConnection( + connection => Assert.Throws( + () => connection.GetStateData(null))); + } + + [Fact, CleanDatabase] + public void GetStateData_ReturnsNull_IfThereIsNoSuchState() + { + UseConnection(connection => + { + var result = connection.GetStateData("1"); + Assert.Null(result); + }); + } + + [Fact, CleanDatabase] + public void GetStateData_ReturnsCorrectData() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, StateName, CreatedAt) +values ('', '', '', getutcdate()); +declare @JobId int; +set @JobId = scope_identity(); +insert into HangFire.State (JobId, Name, CreatedAt) +values (@JobId, 'old-state', getutcdate()); +insert into HangFire.State (JobId, Name, Reason, Data, CreatedAt) +values (@JobId, @name, @reason, @data, getutcdate()); +declare @StateId int; +set @StateId = scope_identity(); +update HangFire.Job set StateId = @StateId; +select @JobId as Id;"; + + UseConnections((sql, connection) => + { + var data = new Dictionary + { + { "Key", "Value" } + }; + + var jobId = (int)sql.Query( + arrangeSql, + new { name = "Name", reason = "Reason", @data = JobHelper.ToJson(data) }).Single().Id; + + var result = connection.GetStateData(jobId.ToString()); + Assert.NotNull(result); + + Assert.Equal("Name", result.Name); + Assert.Equal("Reason", result.Reason); + Assert.Equal("Value", result.Data["Key"]); + }); + } + + [Fact, CleanDatabase] + public void GetStateData_ReturnsCorrectData_WhenPropertiesAreCamelcased() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, StateName, CreatedAt) +values ('', '', '', getutcdate()); +declare @JobId int; +set @JobId = scope_identity(); +insert into HangFire.State (JobId, Name, CreatedAt) +values (@JobId, 'old-state', getutcdate()); +insert into HangFire.State (JobId, Name, Reason, Data, CreatedAt) +values (@JobId, @name, @reason, @data, getutcdate()); +declare @StateId int; +set @StateId = scope_identity(); +update HangFire.Job set StateId = @StateId; +select @JobId as Id;"; + + UseConnections((sql, connection) => + { + var data = new Dictionary + { + { "key", "Value" } + }; + + var jobId = (int)sql.Query( + arrangeSql, + new { name = "Name", reason = "Reason", @data = JobHelper.ToJson(data) }).Single().Id; + + var result = connection.GetStateData(jobId.ToString()); + Assert.NotNull(result); + + Assert.Equal("Value", result.Data["Key"]); + }); + } + + [Fact, CleanDatabase] + public void GetJobData_ReturnsJobLoadException_IfThereWasADeserializationException() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, StateName, CreatedAt) +values (@invocationData, @arguments, @stateName, getutcdate()) +select scope_identity() as Id"; + + UseConnections((sql, connection) => + { + var jobId = sql.Query( + arrangeSql, + new + { + invocationData = JobHelper.ToJson(new InvocationData(null, null, null, null)), + stateName = "Succeeded", + arguments = "['Arguments']" + }).Single(); + + var result = connection.GetJobData(((int)jobId.Id).ToString()); + + Assert.NotNull(result.LoadException); + }); + } + + [Fact, CleanDatabase] + public void SetParameter_ThrowsAnException_WhenJobIdIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.SetJobParameter(null, "name", "value")); + + Assert.Equal("id", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetParameter_ThrowsAnException_WhenNameIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.SetJobParameter("1", null, "value")); + + Assert.Equal("name", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetParameters_CreatesNewParameter_WhenParameterWithTheGivenNameDoesNotExists() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values ('', '', getutcdate()) +select scope_identity() as Id"; + + UseConnections((sql, connection) => + { + var job = sql.Query(arrangeSql).Single(); + string jobId = job.Id.ToString(); + + connection.SetJobParameter(jobId, "Name", "Value"); + + var parameter = sql.Query( + "select * from HangFire.JobParameter where JobId = @id and Name = @name", + new { id = jobId, name = "Name" }).Single(); + + Assert.Equal("Value", parameter.Value); + }); + } + + [Fact, CleanDatabase] + public void SetParameter_UpdatesValue_WhenParameterWithTheGivenName_AlreadyExists() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values ('', '', getutcdate()) +select scope_identity() as Id"; + + UseConnections((sql, connection) => + { + var job = sql.Query(arrangeSql).Single(); + string jobId = job.Id.ToString(); + + connection.SetJobParameter(jobId, "Name", "Value"); + connection.SetJobParameter(jobId, "Name", "AnotherValue"); + + var parameter = sql.Query( + "select * from HangFire.JobParameter where JobId = @id and Name = @name", + new { id = jobId, name = "Name" }).Single(); + + Assert.Equal("AnotherValue", parameter.Value); + }); + } + + [Fact, CleanDatabase] + public void SetParameter_CanAcceptNulls_AsValues() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values ('', '', getutcdate()) +select scope_identity() as Id"; + + UseConnections((sql, connection) => + { + var job = sql.Query(arrangeSql).Single(); + string jobId = job.Id.ToString(); + + connection.SetJobParameter(jobId, "Name", null); + + var parameter = sql.Query( + "select * from HangFire.JobParameter where JobId = @id and Name = @name", + new { id = jobId, name = "Name" }).Single(); + + Assert.Equal((string) null, parameter.Value); + }); + } + + [Fact, CleanDatabase] + public void GetParameter_ThrowsAnException_WhenJobIdIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.GetJobParameter(null, "hello")); + + Assert.Equal("id", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void GetParameter_ThrowsAnException_WhenNameIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.GetJobParameter("1", null)); + + Assert.Equal("name", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void GetParameter_ReturnsNull_WhenParameterDoesNotExists() + { + UseConnection(connection => + { + var value = connection.GetJobParameter("1", "hello"); + Assert.Null(value); + }); + } + + [Fact, CleanDatabase] + public void GetParameter_ReturnsParameterValue_WhenJobExists() + { + const string arrangeSql = @" +declare @id int +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values ('', '', getutcdate()) +set @id = scope_identity() +insert into HangFire.JobParameter (JobId, Name, Value) +values (@id, @name, @value) +select @id"; + + UseConnections((sql, connection) => + { + var id = sql.Query( + arrangeSql, + new { name = "name", value = "value" }).Single(); + + var value = connection.GetJobParameter(id.ToString(), "name"); + + Assert.Equal("value", value); + }); + } + + [Fact, CleanDatabase] + public void GetFirstByLowestScoreFromSet_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.GetFirstByLowestScoreFromSet(null, 0, 1)); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void GetFirstByLowestScoreFromSet_ThrowsAnException_ToScoreIsLowerThanFromScore() + { + UseConnection(connection => Assert.Throws( + () => connection.GetFirstByLowestScoreFromSet("key", 0, -1))); + } + + [Fact, CleanDatabase] + public void GetFirstByLowestScoreFromSet_ReturnsNull_WhenTheKeyDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetFirstByLowestScoreFromSet( + "key", 0, 1); + + Assert.Null(result); + }); + } + + [Fact, CleanDatabase] + public void GetFirstByLowestScoreFromSet_ReturnsTheValueWithTheLowestScore() + { + const string arrangeSql = @" +insert into HangFire.[Set] ([Key], Score, Value) +values +('key', 1.0, '1.0'), +('key', -1.0, '-1.0'), +('key', -5.0, '-5.0'), +('another-key', -2.0, '-2.0')"; + + UseConnections((sql, connection) => + { + sql.Execute(arrangeSql); + + var result = connection.GetFirstByLowestScoreFromSet("key", -1.0, 3.0); + + Assert.Equal("-1.0", result); + }); + } + + [Fact, CleanDatabase] + public void AnnounceServer_ThrowsAnException_WhenServerIdIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.AnnounceServer(null, new ServerContext())); + + Assert.Equal("serverId", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void AnnounceServer_ThrowsAnException_WhenContextIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.AnnounceServer("server", null)); + + Assert.Equal("context", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void AnnounceServer_CreatesOrUpdatesARecord() + { + UseConnections((sql, connection) => + { + var context1 = new ServerContext + { + Queues = new[] { "critical", "default" }, + WorkerCount = 4 + }; + connection.AnnounceServer("server", context1); + + var server = sql.Query("select * from HangFire.Server").Single(); + Assert.Equal("server", server.Id); + Assert.True(((string)server.Data).StartsWith( + "{\"WorkerCount\":4,\"Queues\":[\"critical\",\"default\"],\"StartedAt\":"), + server.Data); + Assert.NotNull(server.LastHeartbeat); + + var context2 = new ServerContext + { + Queues = new[] { "default" }, + WorkerCount = 1000 + }; + connection.AnnounceServer("server", context2); + var sameServer = sql.Query("select * from HangFire.Server").Single(); + Assert.Equal("server", sameServer.Id); + Assert.Contains("1000", sameServer.Data); + }); + } + + [Fact, CleanDatabase] + public void RemoveServer_ThrowsAnException_WhenServerIdIsNull() + { + UseConnection(connection => Assert.Throws( + () => connection.RemoveServer(null))); + } + + [Fact, CleanDatabase] + public void RemoveServer_RemovesAServerRecord() + { + const string arrangeSql = @" +insert into HangFire.Server (Id, Data, LastHeartbeat) +values +('Server1', '', getutcdate()), +('Server2', '', getutcdate())"; + + UseConnections((sql, connection) => + { + sql.Execute(arrangeSql); + + connection.RemoveServer("Server1"); + + var server = sql.Query("select * from HangFire.Server").Single(); + Assert.NotEqual("Server1", server.Id, StringComparer.OrdinalIgnoreCase); + }); + } + + [Fact, CleanDatabase] + public void Heartbeat_ThrowsAnException_WhenServerIdIsNull() + { + UseConnection(connection => Assert.Throws( + () => connection.Heartbeat(null))); + } + + [Fact, CleanDatabase] + public void Heartbeat_UpdatesLastHeartbeat_OfTheServerWithGivenId() + { + const string arrangeSql = @" +insert into HangFire.Server (Id, Data, LastHeartbeat) +values +('server1', '', '2012-12-12 12:12:12'), +('server2', '', '2012-12-12 12:12:12')"; + + UseConnections((sql, connection) => + { + sql.Execute(arrangeSql); + + connection.Heartbeat("server1"); + + var servers = sql.Query("select * from HangFire.Server") + .ToDictionary(x => (string)x.Id, x => (DateTime)x.LastHeartbeat); + + Assert.NotEqual(2012, servers["server1"].Year); + Assert.Equal(2012, servers["server2"].Year); + }); + } + + [Fact, CleanDatabase] + public void RemoveTimedOutServers_ThrowsAnException_WhenTimeOutIsNegative() + { + UseConnection(connection => Assert.Throws( + () => connection.RemoveTimedOutServers(TimeSpan.FromMinutes(-5)))); + } + + [Fact, CleanDatabase] + public void RemoveTimedOutServers_DoItsWorkPerfectly() + { + const string arrangeSql = @" +insert into HangFire.Server (Id, Data, LastHeartbeat) +values (@id, '', @heartbeat)"; + + UseConnections((sql, connection) => + { + sql.Execute( + arrangeSql, + new[] + { + new { id = "server1", heartbeat = DateTime.UtcNow.AddDays(-1) }, + new { id = "server2", heartbeat = DateTime.UtcNow.AddHours(-12) } + }); + + connection.RemoveTimedOutServers(TimeSpan.FromHours(15)); + + var liveServer = sql.Query("select * from HangFire.Server").Single(); + Assert.Equal("server2", liveServer.Id); + }); + } + + [Fact, CleanDatabase] + public void GetAllItemsFromSet_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + Assert.Throws(() => connection.GetAllItemsFromSet(null))); + } + + [Fact, CleanDatabase] + public void GetAllItemsFromSet_ReturnsEmptyCollection_WhenKeyDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetAllItemsFromSet("some-set"); + + Assert.NotNull(result); + Assert.Equal(0, result.Count); + }); + } + + [Fact, CleanDatabase] + public void GetAllItemsFromSet_ReturnsAllItems() + { + const string arrangeSql = @" +insert into HangFire.[Set] ([Key], Score, Value) +values (@key, 0.0, @value)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "some-set", value = "1" }, + new { key = "some-set", value = "2" }, + new { key = "another-set", value = "3" } + }); + + // Act + var result = connection.GetAllItemsFromSet("some-set"); + + // Assert + Assert.Equal(2, result.Count); + Assert.Contains("1", result); + Assert.Contains("2", result); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.SetRangeInHash(null, new Dictionary())); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_ThrowsAnException_WhenKeyValuePairsArgumentIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.SetRangeInHash("some-hash", null)); + + Assert.Equal("keyValuePairs", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_MergesAllRecords() + { + UseConnections((sql, connection) => + { + connection.SetRangeInHash("some-hash", new Dictionary + { + { "Key1", "Value1" }, + { "Key2", "Value2" } + }); + + var result = sql.Query( + "select * from HangFire.Hash where [Key] = @key", + new { key = "some-hash" }) + .ToDictionary(x => (string)x.Field, x => (string)x.Value); + + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["Key2"]); + }); + } + + [Fact, CleanDatabase] + public void GetAllEntriesFromHash_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + Assert.Throws(() => connection.GetAllEntriesFromHash(null))); + } + + [Fact, CleanDatabase] + public void GetAllEntriesFromHash_ReturnsNull_IfHashDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetAllEntriesFromHash("some-hash"); + Assert.Null(result); + }); + } + + [Fact, CleanDatabase] + public void GetAllEntriesFromHash_ReturnsAllKeysAndTheirValues() + { + const string arrangeSql = @" +insert into HangFire.Hash ([Key], [Field], [Value]) +values (@key, @field, @value)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "some-hash", field = "Key1", value = "Value1" }, + new { key = "some-hash", field = "Key2", value = "Value2" }, + new { key = "another-hash", field = "Key3", value = "Value3" } + }); + + // Act + var result = connection.GetAllEntriesFromHash("some-hash"); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["Key2"]); + }); + } + + [Fact, CleanDatabase] + public void GetSetCount_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + Assert.Throws( + () => connection.GetSetCount(null)); + }); + } + + [Fact, CleanDatabase] + public void GetSetCount_ReturnsZero_WhenSetDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetSetCount("my-set"); + Assert.Equal(0, result); + }); + } + + [Fact, CleanDatabase] + public void GetSetCount_ReturnsNumberOfElements_InASet() + { + const string arrangeSql = @" +insert into HangFire.[Set] ([Key], [Value], [Score]) +values (@key, @value, 0.0)"; + + UseConnections((sql, connection) => + { + sql.Execute(arrangeSql, new List + { + new { key = "set-1", value = "value-1" }, + new { key = "set-2", value = "value-1" }, + new { key = "set-1", value = "value-2" } + }); + + var result = connection.GetSetCount("set-1"); + + Assert.Equal(2, result); + }); + } + + [Fact, CleanDatabase] + public void GetRangeFromSet_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + Assert.Throws(() => connection.GetRangeFromSet(null, 0, 1)); + }); + } + + [Fact, CleanDatabase] + public void GetRangeFromSet_ReturnsPagedElements() + { + const string arrangeSql = @" +insert into HangFire.[Set] ([Key], [Value], [Score]) +values (@Key, @Value, 0.0)"; + + UseConnections((sql, connection) => + { + sql.Execute(arrangeSql, new List + { + new { Key = "set-1", Value = "1" }, + new { Key = "set-1", Value = "2" }, + new { Key = "set-1", Value = "3" }, + new { Key = "set-1", Value = "4" }, + new { Key = "set-2", Value = "4" }, + new { Key = "set-1", Value = "5" } + }); + + var result = connection.GetRangeFromSet("set-1", 2, 3); + + Assert.Equal(new [] { "3", "4" }, result); + }); + } + + [Fact, CleanDatabase] + public void GetCounter_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + Assert.Throws( + () => connection.GetCounter(null)); + }); + } + + [Fact, CleanDatabase] + public void GetCounter_ReturnsZero_WhenKeyDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetCounter("my-counter"); + Assert.Equal(0, result); + }); + } + + [Fact, CleanDatabase] + public void GetCounter_ReturnsSumOfValues_InCounterTable() + { + const string arrangeSql = @" +insert into HangFire.Counter ([Key], [Value]) +values (@key, @value)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "counter-1", value = 1 }, + new { key = "counter-2", value = 1 }, + new { key = "counter-1", value = 1 } + }); + + // Act + var result = connection.GetCounter("counter-1"); + + // Assert + Assert.Equal(2, result); + }); + } + + [Fact, CleanDatabase] + public void GetCounter_IncludesValues_FromCounterAggregateTable() + { + const string arrangeSql = @" +insert into HangFire.AggregatedCounter ([Key], [Value]) +values (@key, @value)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "counter-1", value = 12 }, + new { key = "counter-2", value = 15 } + }); + + // Act + var result = connection.GetCounter("counter-1"); + + Assert.Equal(12, result); + }); + } + + [Fact, CleanDatabase] + public void GetHashCount_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + Assert.Throws(() => connection.GetHashCount(null)); + }); + } + + [Fact, CleanDatabase] + public void GetHashCount_ReturnsZero_WhenKeyDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetHashCount("my-hash"); + Assert.Equal(0, result); + }); + } + + [Fact, CleanDatabase] + public void GetHashCount_ReturnsNumber_OfHashFields() + { + const string arrangeSql = @" +insert into HangFire.Hash ([Key], [Field]) +values (@key, @field)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "hash-1", field = "field-1" }, + new { key = "hash-1", field = "field-2" }, + new { key = "hash-2", field = "field-1" } + }); + + // Act + var result = connection.GetHashCount("hash-1"); + + // Assert + Assert.Equal(2, result); + }); + } + + [Fact, CleanDatabase] + public void GetHashTtl_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + Assert.Throws( + () => connection.GetHashTtl(null)); + }); + } + + [Fact, CleanDatabase] + public void GetHashTtl_ReturnsNegativeValue_WhenHashDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetHashTtl("my-hash"); + Assert.True(result < TimeSpan.Zero); + }); + } + + [Fact, CleanDatabase] + public void GetHashTtl_ReturnsExpirationTimeForHash() + { + const string arrangeSql = @" +insert into HangFire.Hash ([Key], [Field], [ExpireAt]) +values (@key, @field, @expireAt)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "hash-1", field = "field", expireAt = (DateTime?)DateTime.UtcNow.AddHours(1) }, + new { key = "hash-2", field = "field", expireAt = (DateTime?) null } + }); + + // Act + var result = connection.GetHashTtl("hash-1"); + + // Assert + Assert.True(TimeSpan.FromMinutes(59) < result); + Assert.True(result < TimeSpan.FromMinutes(61)); + }); + } + + [Fact, CleanDatabase] + public void GetListCount_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + Assert.Throws( + () => connection.GetListCount(null)); + }); + } + + [Fact, CleanDatabase] + public void GetListCount_ReturnsZero_WhenListDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetListCount("my-list"); + Assert.Equal(0, result); + }); + } + + [Fact, CleanDatabase] + public void GetListCount_ReturnsTheNumberOfListElements() + { + const string arrangeSql = @" +insert into HangFire.List ([Key]) +values (@key)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "list-1" }, + new { key = "list-1" }, + new { key = "list-2" } + }); + + // Act + var result = connection.GetListCount("list-1"); + + // Assert + Assert.Equal(2, result); + }); + } + + [Fact, CleanDatabase] + public void GetListTtl_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + Assert.Throws( + () => connection.GetListTtl(null)); + }); + } + + [Fact, CleanDatabase] + public void GetListTtl_ReturnsNegativeValue_WhenListDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetListTtl("my-list"); + Assert.True(result < TimeSpan.Zero); + }); + } + + [Fact, CleanDatabase] + public void GetListTtl_ReturnsExpirationTimeForList() + { + const string arrangeSql = @" +insert into HangFire.List ([Key], [ExpireAt]) +values (@key, @expireAt)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "list-1", expireAt = (DateTime?) DateTime.UtcNow.AddHours(1) }, + new { key = "list-2", expireAt = (DateTime?) null } + }); + + // Act + var result = connection.GetListTtl("list-1"); + + // Assert + Assert.True(TimeSpan.FromMinutes(59) < result); + Assert.True(result < TimeSpan.FromMinutes(61)); + }); + } + + [Fact, CleanDatabase] + public void GetValueFromHash_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.GetValueFromHash(null, "name")); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void GetValueFromHash_ThrowsAnException_WhenNameIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.GetValueFromHash("key", null)); + + Assert.Equal("name", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void GetValueFromHash_ReturnsNull_WhenHashDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetValueFromHash("my-hash", "name"); + Assert.Null(result); + }); + } + + [Fact, CleanDatabase] + public void GetValueFromHash_ReturnsValue_OfAGivenField() + { + const string arrangeSql = @" +insert into HangFire.Hash ([Key], [Field], [Value]) +values (@key, @field, @value)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "hash-1", field = "field-1", value = "1" }, + new { key = "hash-1", field = "field-2", value = "2" }, + new { key = "hash-2", field = "field-1", value = "3" } + }); + + // Act + var result = connection.GetValueFromHash("hash-1", "field-1"); + + // Assert + Assert.Equal("1", result); + }); + } + + [Fact, CleanDatabase] + public void GetRangeFromList_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + var exception = Assert.Throws( + () => connection.GetRangeFromList(null, 0, 1)); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void GetRangeFromList_ReturnsAnEmptyList_WhenListDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetRangeFromList("my-list", 0, 1); + Assert.Empty(result); + }); + } + + [Fact, CleanDatabase] + public void GetRangeFromList_ReturnsAllEntries_WithinGivenBounds() + { + const string arrangeSql = @" +insert into HangFire.List ([Key], [Value]) +values (@key, @value)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "list-1", value = "1" }, + new { key = "list-2", value = "2" }, + new { key = "list-1", value = "3" }, + new { key = "list-1", value = "4" }, + new { key = "list-1", value = "5" } + }); + + // Act + var result = connection.GetRangeFromList("list-1", 1, 2); + + // Assert + Assert.Equal(new [] { "4", "3" }, result); + }); + } + + [Fact, CleanDatabase] + public void GetAllItemsFromList_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + Assert.Throws( + () => connection.GetAllItemsFromList(null)); + }); + } + + [Fact, CleanDatabase] + public void GetAllItemsFromList_ReturnsAnEmptyList_WhenListDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetAllItemsFromList("my-list"); + Assert.Empty(result); + }); + } + + [Fact, CleanDatabase] + public void GetAllItemsFromList_ReturnsAllItems_FromAGivenList() + { + const string arrangeSql = @" +insert into HangFire.List ([Key], Value) +values (@key, @value)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "list-1", value = "1" }, + new { key = "list-2", value = "2" }, + new { key = "list-1", value = "3" } + }); + + // Act + var result = connection.GetAllItemsFromList("list-1"); + + // Assert + Assert.Equal(new [] { "3", "1" }, result); + }); + } + + [Fact, CleanDatabase] + public void GetSetTtl_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(connection => + { + Assert.Throws(() => connection.GetSetTtl(null)); + }); + } + + [Fact, CleanDatabase] + public void GetSetTtl_ReturnsNegativeValue_WhenSetDoesNotExist() + { + UseConnection(connection => + { + var result = connection.GetSetTtl("my-set"); + Assert.True(result < TimeSpan.Zero); + }); + } + + [Fact, CleanDatabase] + public void GetSetTtl_ReturnsExpirationTime_OfAGivenSet() + { + const string arrangeSql = @" +insert into HangFire.[Set] ([Key], [Value], [ExpireAt], [Score]) +values (@key, @value, @expireAt, 0.0)"; + + UseConnections((sql, connection) => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "set-1", value = "1", expireAt = (DateTime?) DateTime.UtcNow.AddMinutes(60) }, + new { key = "set-2", value = "2", expireAt = (DateTime?) null } + }); + + // Act + var result = connection.GetSetTtl("set-1"); + + // Assert + Assert.True(TimeSpan.FromMinutes(59) < result); + Assert.True(result < TimeSpan.FromMinutes(61)); + }); + } + + private void UseConnections(Action action) + { + using (var sqlConnection = ConnectionUtils.CreateConnection()) + { + var storage = new SqlServerStorage(sqlConnection); + using (var connection = new SqlServerConnection(storage)) + { + action(sqlConnection, connection); + } + } + } + + private void UseConnection(Action action) + { + using (var sql = ConnectionUtils.CreateConnection()) + { + var storage = new Mock(sql); + storage.Setup(x => x.QueueProviders).Returns(_providers); + + using (var connection = new SqlServerConnection(storage.Object)) + { + action(connection); + } + } + } + + public static void SampleMethod(string arg){ } + } +} diff --git a/tests/Hangfire.SqlServer.Tests/SqlServerDistributedLockFacts.cs b/tests/Hangfire.SqlServer.Tests/SqlServerDistributedLockFacts.cs new file mode 100644 index 0000000..f1791ef --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/SqlServerDistributedLockFacts.cs @@ -0,0 +1,146 @@ +using System; +using System.Data.SqlClient; +using System.Linq; +using System.Threading; +using System.Transactions; +using Dapper; +using Hangfire.Storage; +using Xunit; + +namespace Hangfire.SqlServer.Tests +{ + public class SqlServerDistributedLockFacts + { + private readonly TimeSpan _timeout = TimeSpan.FromSeconds(5); + + [Fact] + public void Ctor_ThrowsAnException_WhenStorageIsNull() + { + var exception = Assert.Throws( + () => new SqlServerDistributedLock(null, "hello", _timeout)); + + Assert.Equal("storage", exception.ParamName); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenTimeoutTooLarge() + { + UseConnection(connection => + { + var storage = CreateStorage(connection); + var tooLargeTimeout = TimeSpan.FromSeconds(Int32.MaxValue); + var exception = Assert.Throws(() => new SqlServerDistributedLock(storage, "hello", tooLargeTimeout)); + + Assert.Equal("timeout", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void Ctor_ThrowsAnException_WhenResourceIsNullOrEmpty() + { + UseConnection(connection => + { + var storage = CreateStorage(connection); + + var exception = Assert.Throws( + () => new SqlServerDistributedLock(storage, "", _timeout)); + + Assert.Equal("resource", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void Ctor_AcquiresExclusiveApplicationLock_OnSession() + { + UseConnection(sql => + { + // ReSharper disable once UnusedVariable + var storage = CreateStorage(sql); + using (new SqlServerDistributedLock(storage, "hello", _timeout)) + { + var lockMode = sql.Query( + "select applock_mode('public', 'hello', 'session')").Single(); + + Assert.Equal("Exclusive", lockMode); + } + }); + } + + [Fact, CleanDatabase] + public void Ctor_ThrowsAnException_IfLockCanNotBeGranted() + { + var releaseLock = new ManualResetEventSlim(false); + var lockAcquired = new ManualResetEventSlim(false); + + var thread = new Thread( + () => UseConnection(connection1 => + { + var storage = CreateStorage(connection1); + using (new SqlServerDistributedLock(storage, "exclusive", _timeout)) + { + lockAcquired.Set(); + releaseLock.Wait(); + } + })); + thread.Start(); + + lockAcquired.Wait(); + + UseConnection(connection2 => + { + var storage = CreateStorage(connection2); + Assert.Throws( + () => + { + using (new SqlServerDistributedLock(storage, "exclusive", _timeout)) + { + } + }); + }); + + releaseLock.Set(); + thread.Join(); + } + + [Fact, CleanDatabase] + public void Dispose_ReleasesExclusiveApplicationLock() + { + UseConnection(sql => + { + var storage = CreateStorage(sql); + var distributedLock = new SqlServerDistributedLock(storage, "hello", _timeout); + distributedLock.Dispose(); + + var lockMode = sql.Query( + "select applock_mode('public', 'hello', 'session')").Single(); + + Assert.Equal("NoLock", lockMode); + }); + } + + [Fact(Timeout = 1000), CleanDatabase(IsolationLevel.Unspecified)] + public void DistributedLocks_AreReEntrant_FromTheSameThread_OnTheSameResource() + { + var storage = new SqlServerStorage(ConnectionUtils.GetConnectionString()); + + using (new SqlServerDistributedLock(storage, "hello", TimeSpan.FromMinutes(5))) + using (new SqlServerDistributedLock(storage, "hello", TimeSpan.FromMinutes(5))) + { + Assert.True(true); + } + } + + private SqlServerStorage CreateStorage(SqlConnection connection) + { + return new SqlServerStorage(connection); + } + + private void UseConnection(Action action) + { + using (var connection = ConnectionUtils.CreateConnection()) + { + action(connection); + } + } + } +} diff --git a/tests/Hangfire.SqlServer.Tests/SqlServerFetchedJobFacts.cs b/tests/Hangfire.SqlServer.Tests/SqlServerFetchedJobFacts.cs new file mode 100644 index 0000000..dc76775 --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/SqlServerFetchedJobFacts.cs @@ -0,0 +1,125 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using System.Linq; +using Dapper; +using Moq; +using Xunit; + +namespace Hangfire.SqlServer.Tests +{ + public class SqlServerFetchedJobFacts + { + private const string JobId = "id"; + private const string Queue = "queue"; + + private readonly Mock _connection; + private readonly Mock _transaction; + private readonly Mock _storage; + + public SqlServerFetchedJobFacts() + { + _connection = new Mock(); + _transaction = new Mock(); + _storage = new Mock(ConnectionUtils.GetConnectionString()); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenStorageIsNull() + { + var exception = Assert.Throws( + () => new SqlServerFetchedJob(null, _connection.Object, _transaction.Object, JobId, Queue)); + + Assert.Equal("storage", exception.ParamName); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenConnectionIsNull() + { + var exception = Assert.Throws( + () => new SqlServerFetchedJob(_storage.Object, null, _transaction.Object, JobId, Queue)); + + Assert.Equal("connection", exception.ParamName); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenTransactionIsNull() + { + var exception = Assert.Throws( + () => new SqlServerFetchedJob(_storage.Object, _connection.Object, null, JobId, Queue)); + + Assert.Equal("transaction", exception.ParamName); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenJobIdIsNull() + { + var exception = Assert.Throws( + () => new SqlServerFetchedJob(_storage.Object, _connection.Object, _transaction.Object, null, Queue)); + + Assert.Equal("jobId", exception.ParamName); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenQueueIsNull() + { + var exception = Assert.Throws( + () => new SqlServerFetchedJob(_storage.Object, _connection.Object, _transaction.Object, JobId, null)); + + Assert.Equal("queue", exception.ParamName); + } + + [Fact] + public void Ctor_CorrectlySets_AllInstanceProperties() + { + var fetchedJob = new SqlServerFetchedJob(_storage.Object, _connection.Object, _transaction.Object, JobId, Queue); + + Assert.Equal(JobId, fetchedJob.JobId); + Assert.Equal(Queue, fetchedJob.Queue); + } + + [Fact, CleanDatabase] + public void RemoveFromQueue_CommitsTheTransaction() + { + // Arrange + var processingJob = CreateFetchedJob("1", "default"); + + // Act + processingJob.RemoveFromQueue(); + + // Assert + _transaction.Verify(x => x.Commit()); + } + + [Fact, CleanDatabase] + public void Requeue_RollsbackTheTransaction() + { + // Arrange + var processingJob = CreateFetchedJob("1", "default"); + + // Act + processingJob.Requeue(); + + // Assert + _transaction.Verify(x => x.Rollback()); + } + + [Fact, CleanDatabase] + public void Dispose_DisposesTheTransactionAndConnection() + { + var processingJob = CreateFetchedJob("1", "queue"); + + // Act + processingJob.Dispose(); + + // Assert + _transaction.Verify(x => x.Dispose()); + _connection.Verify(x => x.Dispose()); + } + + private SqlServerFetchedJob CreateFetchedJob(string jobId, string queue) + { + return new SqlServerFetchedJob(_storage.Object, _connection.Object, _transaction.Object, jobId, queue); + } + } +} diff --git a/tests/Hangfire.SqlServer.Tests/SqlServerJobQueueFacts.cs b/tests/Hangfire.SqlServer.Tests/SqlServerJobQueueFacts.cs new file mode 100644 index 0000000..25f96d9 --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/SqlServerJobQueueFacts.cs @@ -0,0 +1,312 @@ +using System; +using System.Data; +using System.Data.SqlClient; +using System.Linq; +using System.Threading; +using Dapper; +using Xunit; + +namespace Hangfire.SqlServer.Tests +{ + public class SqlServerJobQueueFacts + { + private static readonly string[] DefaultQueues = { "default" }; + + [Fact] + public void Ctor_ThrowsAnException_WhenStorageIsNull() + { + var exception = Assert.Throws( + () => new SqlServerJobQueue(null, new SqlServerStorageOptions())); + + Assert.Equal("storage", exception.ParamName); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenOptionsValueIsNull() + { + var exception = Assert.Throws( + () => new SqlServerJobQueue(new SqlServerStorage(ConnectionUtils.GetConnectionString()), null)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldThrowAnException_WhenQueuesCollectionIsNull() + { + UseConnection(connection => + { + var queue = CreateJobQueue(connection); + + var exception = Assert.Throws( + () => queue.Dequeue(null, CreateTimingOutCancellationToken())); + + Assert.Equal("queues", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldThrowAnException_WhenQueuesCollectionIsEmpty() + { + UseConnection(connection => + { + var queue = CreateJobQueue(connection); + + var exception = Assert.Throws( + () => queue.Dequeue(new string[0], CreateTimingOutCancellationToken())); + + Assert.Equal("queues", exception.ParamName); + }); + } + + [Fact] + public void Dequeue_ThrowsOperationCanceled_WhenCancellationTokenIsSetAtTheBeginning() + { + UseConnection(connection => + { + var cts = new CancellationTokenSource(); + cts.Cancel(); + var queue = CreateJobQueue(connection); + + Assert.Throws( + () => queue.Dequeue(DefaultQueues, cts.Token)); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldWaitIndefinitely_WhenThereAreNoJobs() + { + UseConnection(connection => + { + var cts = new CancellationTokenSource(200); + var queue = CreateJobQueue(connection); + + Assert.Throws( + () => queue.Dequeue(DefaultQueues, cts.Token)); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldFetchAJob_FromTheSpecifiedQueue() + { + const string arrangeSql = @" +insert into HangFire.JobQueue (JobId, Queue) +values (@jobId, @queue); +select scope_identity() as Id;"; + + // Arrange + UseConnection(connection => + { + var id = (int)connection.Query( + arrangeSql, + new { jobId = 1, queue = "default" }).Single().Id; + var queue = CreateJobQueue(connection); + + // Act + var payload = (SqlServerFetchedJob)queue.Dequeue( + DefaultQueues, + CreateTimingOutCancellationToken()); + + // Assert + Assert.Equal("1", payload.JobId); + Assert.Equal("default", payload.Queue); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldDeleteAJob() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values (@invocationData, @arguments, getutcdate()) +insert into HangFire.JobQueue (JobId, Queue) +values (scope_identity(), @queue)"; + + // Arrange + UseConnection(connection => + { + connection.Execute( + arrangeSql, + new { invocationData = "", arguments = "", queue = "default" }); + var queue = CreateJobQueue(connection); + + // Act + var payload = queue.Dequeue( + DefaultQueues, + CreateTimingOutCancellationToken()); + + // Assert + Assert.NotNull(payload); + + var jobInQueue = connection.Query("select * from HangFire.JobQueue").SingleOrDefault(); + Assert.Null(jobInQueue); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldFetchATimedOutJobs_FromTheSpecifiedQueue() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values (@invocationData, @arguments, getutcdate()) +insert into HangFire.JobQueue (JobId, Queue, FetchedAt) +values (scope_identity(), @queue, @fetchedAt)"; + + // Arrange + UseConnection(connection => + { + connection.Execute( + arrangeSql, + new + { + queue = "default", + fetchedAt = DateTime.UtcNow.AddDays(-1), + invocationData = "", + arguments = "" + }); + var queue = CreateJobQueue(connection); + + // Act + var payload = queue.Dequeue( + DefaultQueues, + CreateTimingOutCancellationToken()); + + // Assert + Assert.NotEmpty(payload.JobId); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldSetFetchedAt_OnlyForTheFetchedJob() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values (@invocationData, @arguments, getutcdate()) +insert into HangFire.JobQueue (JobId, Queue) +values (scope_identity(), @queue)"; + + // Arrange + UseConnection(connection => + { + connection.Execute( + arrangeSql, + new[] + { + new { queue = "default", invocationData = "", arguments = "" }, + new { queue = "default", invocationData = "", arguments = "" } + }); + var queue = CreateJobQueue(connection); + + // Act + var payload = queue.Dequeue( + DefaultQueues, + CreateTimingOutCancellationToken()); + + // Assert + var otherJobFetchedAt = connection.Query( + "select FetchedAt from HangFire.JobQueue where JobId != @id", + new { id = payload.JobId }).Single(); + + Assert.Null(otherJobFetchedAt); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldFetchJobs_OnlyFromSpecifiedQueues() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values (@invocationData, @arguments, getutcdate()) +insert into HangFire.JobQueue (JobId, Queue) +values (scope_identity(), @queue)"; + + UseConnection(connection => + { + var queue = CreateJobQueue(connection); + + connection.Execute( + arrangeSql, + new { queue = "critical", invocationData = "", arguments = "" }); + + Assert.Throws( + () => queue.Dequeue( + DefaultQueues, + CreateTimingOutCancellationToken())); + }); + } + + [Fact, CleanDatabase] + public void Dequeue_ShouldFetchJobs_FromMultipleQueues() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values (@invocationData, @arguments, getutcdate()) +insert into HangFire.JobQueue (JobId, Queue) +values (scope_identity(), @queue)"; + + UseConnection(connection => + { + connection.Execute( + arrangeSql, + new[] + { + new { queue = "default", invocationData = "", arguments = "" }, + new { queue = "critical", invocationData = "", arguments = "" } + }); + + var queue = CreateJobQueue(connection); + + var critical = (SqlServerFetchedJob)queue.Dequeue( + new[] { "critical", "default" }, + CreateTimingOutCancellationToken()); + + Assert.NotNull(critical.JobId); + Assert.Equal("critical", critical.Queue); + + var @default = (SqlServerFetchedJob)queue.Dequeue( + new[] { "critical", "default" }, + CreateTimingOutCancellationToken()); + + Assert.NotNull(@default.JobId); + Assert.Equal("default", @default.Queue); + }); + } + + [Fact, CleanDatabase] + public void Enqueue_AddsAJobToTheQueue() + { + UseConnection(connection => + { + var queue = CreateJobQueue(connection); + + queue.Enqueue(connection, "default", "1"); + + var record = connection.Query("select * from HangFire.JobQueue").Single(); + Assert.Equal("1", record.JobId.ToString()); + Assert.Equal("default", record.Queue); + Assert.Null(record.FetchedAt); + }); + } + + private static CancellationToken CreateTimingOutCancellationToken() + { + var source = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + return source.Token; + } + + public static void Sample(string arg1, string arg2) { } + + private static SqlServerJobQueue CreateJobQueue(SqlConnection connection) + { + var storage = new SqlServerStorage(connection); + return new SqlServerJobQueue(storage, new SqlServerStorageOptions()); + } + + private static void UseConnection(Action action) + { + using (var connection = ConnectionUtils.CreateConnection()) + { + action(connection); + } + } + } +} diff --git a/tests/Hangfire.SqlServer.Tests/SqlServerStorageFacts.cs b/tests/Hangfire.SqlServer.Tests/SqlServerStorageFacts.cs new file mode 100644 index 0000000..7be2eb6 --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/SqlServerStorageFacts.cs @@ -0,0 +1,80 @@ +using System; +using System.Linq; +using System.Transactions; +using Xunit; + +namespace Hangfire.SqlServer.Tests +{ + public class SqlServerStorageFacts + { + private readonly SqlServerStorageOptions _options; + + public SqlServerStorageFacts() + { + _options = new SqlServerStorageOptions { PrepareSchemaIfNecessary = false }; + } + + [Fact] + public void Ctor_ThrowsAnException_WhenConnectionStringIsNull() + { + var exception = Assert.Throws( + () => new SqlServerStorage((string)null)); + + Assert.Equal("nameOrConnectionString", exception.ParamName); + } + + [Fact] + public void Ctor_ThrowsAnException_WhenOptionsValueIsNull() + { + var exception = Assert.Throws( + () => new SqlServerStorage("hello", null)); + + Assert.Equal("options", exception.ParamName); + } + + [Fact, CleanDatabase] + public void Ctor_CanCreateSqlServerStorage_WithExistingConnection() + { + var connection = ConnectionUtils.CreateConnection(); + var storage = new SqlServerStorage(connection); + + Assert.NotNull(storage); + } + + [Fact, CleanDatabase(isolationLevel: IsolationLevel.ReadUncommitted)] + public void GetMonitoringApi_ReturnsNonNullInstance() + { + var storage = CreateStorage(); + var api = storage.GetMonitoringApi(); + Assert.NotNull(api); + } + + [Fact, CleanDatabase] + public void GetConnection_ReturnsNonNullInstance() + { + var storage = CreateStorage(); + using (var connection = (SqlServerConnection)storage.GetConnection()) + { + Assert.NotNull(connection); + } + } + + [Fact, CleanDatabase] + public void GetComponents_ReturnsAllNeededComponents() + { + var storage = CreateStorage(); + + var components = storage.GetComponents(); + + var componentTypes = components.Select(x => x.GetType()).ToArray(); + Assert.Contains(typeof(ExpirationManager), componentTypes); + } + + private SqlServerStorage CreateStorage() + { + return new SqlServerStorage( + ConnectionUtils.GetConnectionString(), + _options); + } + } +} diff --git a/tests/Hangfire.SqlServer.Tests/SqlServerWriteOnlyTransactionFacts.cs b/tests/Hangfire.SqlServer.Tests/SqlServerWriteOnlyTransactionFacts.cs new file mode 100644 index 0000000..1ee137b --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/SqlServerWriteOnlyTransactionFacts.cs @@ -0,0 +1,985 @@ +using System; +using System.Collections.Generic; +using System.Data; +using System.Data.SqlClient; +using System.Linq; +using Dapper; +using Hangfire.States; +using Moq; +using Xunit; + +namespace Hangfire.SqlServer.Tests +{ + public class SqlServerWriteOnlyTransactionFacts + { + private readonly PersistentJobQueueProviderCollection _queueProviders; + + public SqlServerWriteOnlyTransactionFacts() + { + var defaultProvider = new Mock(); + defaultProvider.Setup(x => x.GetJobQueue()) + .Returns(new Mock().Object); + + _queueProviders = new PersistentJobQueueProviderCollection(defaultProvider.Object); + } + + [Fact] + public void Ctor_ThrowsAnException_IfStorageIsNull() + { + var exception = Assert.Throws( + () => new SqlServerWriteOnlyTransaction(null)); + + Assert.Equal("storage", exception.ParamName); + } + + [Fact, CleanDatabase] + public void ExpireJob_SetsJobExpirationData() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values ('', '', getutcdate()) +select scope_identity() as Id"; + + UseConnection(sql => + { + var jobId = sql.Query(arrangeSql).Single().Id.ToString(); + var anotherJobId = sql.Query(arrangeSql).Single().Id.ToString(); + + Commit(sql, x => x.ExpireJob(jobId, TimeSpan.FromDays(1))); + + var job = GetTestJob(sql, jobId); + Assert.True(DateTime.UtcNow.AddMinutes(-1) < job.ExpireAt && job.ExpireAt <= DateTime.UtcNow.AddDays(1)); + + var anotherJob = GetTestJob(sql, anotherJobId); + Assert.Null(anotherJob.ExpireAt); + }); + } + + [Fact, CleanDatabase] + public void PersistJob_ClearsTheJobExpirationData() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt, ExpireAt) +values ('', '', getutcdate(), getutcdate()) +select scope_identity() as Id"; + + UseConnection(sql => + { + var jobId = sql.Query(arrangeSql).Single().Id.ToString(); + var anotherJobId = sql.Query(arrangeSql).Single().Id.ToString(); + + Commit(sql, x => x.PersistJob(jobId)); + + var job = GetTestJob(sql, jobId); + Assert.Null(job.ExpireAt); + + var anotherJob = GetTestJob(sql, anotherJobId); + Assert.NotNull(anotherJob.ExpireAt); + }); + } + + [Fact, CleanDatabase] + public void SetJobState_AppendsAStateAndSetItToTheJob() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values ('', '', getutcdate()) +select scope_identity() as Id"; + + UseConnection(sql => + { + var jobId = sql.Query(arrangeSql).Single().Id.ToString(); + var anotherJobId = sql.Query(arrangeSql).Single().Id.ToString(); + + var state = new Mock(); + state.Setup(x => x.Name).Returns("State"); + state.Setup(x => x.Reason).Returns("Reason"); + state.Setup(x => x.SerializeData()) + .Returns(new Dictionary { { "Name", "Value" } }); + + Commit(sql, x => x.SetJobState(jobId, state.Object)); + + var job = GetTestJob(sql, jobId); + Assert.Equal("State", job.StateName); + Assert.NotNull(job.StateId); + + var anotherJob = GetTestJob(sql, anotherJobId); + Assert.Null(anotherJob.StateName); + Assert.Null(anotherJob.StateId); + + var jobState = sql.Query("select * from HangFire.State").Single(); + Assert.Equal((string)jobId, jobState.JobId.ToString()); + Assert.Equal("State", jobState.Name); + Assert.Equal("Reason", jobState.Reason); + Assert.NotNull(jobState.CreatedAt); + Assert.Equal("{\"Name\":\"Value\"}", jobState.Data); + }); + } + + [Fact, CleanDatabase] + public void AddJobState_JustAddsANewRecordInATable() + { + const string arrangeSql = @" +insert into HangFire.Job (InvocationData, Arguments, CreatedAt) +values ('', '', getutcdate()) +select scope_identity() as Id"; + + UseConnection(sql => + { + var jobId = sql.Query(arrangeSql).Single().Id.ToString(); + + var state = new Mock(); + state.Setup(x => x.Name).Returns("State"); + state.Setup(x => x.Reason).Returns("Reason"); + state.Setup(x => x.SerializeData()) + .Returns(new Dictionary { { "Name", "Value" } }); + + Commit(sql, x => x.AddJobState(jobId, state.Object)); + + var job = GetTestJob(sql, jobId); + Assert.Null(job.StateName); + Assert.Null(job.StateId); + + var jobState = sql.Query("select * from HangFire.State").Single(); + Assert.Equal((string)jobId, jobState.JobId.ToString()); + Assert.Equal("State", jobState.Name); + Assert.Equal("Reason", jobState.Reason); + Assert.NotNull(jobState.CreatedAt); + Assert.Equal("{\"Name\":\"Value\"}", jobState.Data); + }); + } + + [Fact, CleanDatabase] + public void AddToQueue_CallsEnqueue_OnTargetPersistentQueue() + { + var correctJobQueue = new Mock(); + var correctProvider = new Mock(); + correctProvider.Setup(x => x.GetJobQueue()) + .Returns(correctJobQueue.Object); + + _queueProviders.Add(correctProvider.Object, new[] { "default" }); + + UseConnection(sql => + { + Commit(sql, x => x.AddToQueue("default", "1")); + + correctJobQueue.Verify(x => x.Enqueue(It.IsNotNull(), "default", "1")); + }); + } + + private static dynamic GetTestJob(IDbConnection connection, string jobId) + { + return connection + .Query("select * from HangFire.Job where Id = @id", new { id = jobId }) + .Single(); + } + + [Fact, CleanDatabase] + public void IncrementCounter_AddsRecordToCounterTable_WithPositiveValue() + { + UseConnection(sql => + { + Commit(sql, x => x.IncrementCounter("my-key")); + + var record = sql.Query("select * from HangFire.Counter").Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal(1, record.Value); + Assert.Equal((DateTime?)null, record.ExpireAt); + }); + } + + [Fact, CleanDatabase] + public void IncrementCounter_WithExpiry_AddsARecord_WithExpirationTimeSet() + { + UseConnection(sql => + { + Commit(sql, x => x.IncrementCounter("my-key", TimeSpan.FromDays(1))); + + var record = sql.Query("select * from HangFire.Counter").Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal(1, record.Value); + Assert.NotNull(record.ExpireAt); + + var expireAt = (DateTime) record.ExpireAt; + + Assert.True(DateTime.UtcNow.AddHours(23) < expireAt); + Assert.True(expireAt < DateTime.UtcNow.AddHours(25)); + }); + } + + [Fact, CleanDatabase] + public void IncrementCounter_WithExistingKey_AddsAnotherRecord() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.IncrementCounter("my-key"); + x.IncrementCounter("my-key"); + }); + + var recordCount = sql.Query("select count(*) from HangFire.Counter").Single(); + + Assert.Equal(2, recordCount); + }); + } + + [Fact, CleanDatabase] + public void DecrementCounter_AddsRecordToCounterTable_WithNegativeValue() + { + UseConnection(sql => + { + Commit(sql, x => x.DecrementCounter("my-key")); + + var record = sql.Query("select * from HangFire.Counter").Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal(-1, record.Value); + Assert.Equal((DateTime?)null, record.ExpireAt); + }); + } + + [Fact, CleanDatabase] + public void DecrementCounter_WithExpiry_AddsARecord_WithExpirationTimeSet() + { + UseConnection(sql => + { + Commit(sql, x => x.DecrementCounter("my-key", TimeSpan.FromDays(1))); + + var record = sql.Query("select * from HangFire.Counter").Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal(-1, record.Value); + Assert.NotNull(record.ExpireAt); + + var expireAt = (DateTime)record.ExpireAt; + + Assert.True(DateTime.UtcNow.AddHours(23) < expireAt); + Assert.True(expireAt < DateTime.UtcNow.AddHours(25)); + }); + } + + [Fact, CleanDatabase] + public void DecrementCounter_WithExistingKey_AddsAnotherRecord() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.DecrementCounter("my-key"); + x.DecrementCounter("my-key"); + }); + + var recordCount = sql.Query("select count(*) from HangFire.Counter").Single(); + + Assert.Equal(2, recordCount); + }); + } + + [Fact, CleanDatabase] + public void AddToSet_AddsARecord_IfThereIsNo_SuchKeyAndValue() + { + UseConnection(sql => + { + Commit(sql, x => x.AddToSet("my-key", "my-value")); + + var record = sql.Query("select * from HangFire.[Set]").Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal("my-value", record.Value); + Assert.Equal(0.0, record.Score, 2); + }); + } + + [Fact, CleanDatabase] + public void AddToSet_AddsARecord_WhenKeyIsExists_ButValuesAreDifferent() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.AddToSet("my-key", "my-value"); + x.AddToSet("my-key", "another-value"); + }); + + var recordCount = sql.Query("select count(*) from HangFire.[Set]").Single(); + + Assert.Equal(2, recordCount); + }); + } + + [Fact, CleanDatabase] + public void AddToSet_DoesNotAddARecord_WhenBothKeyAndValueAreExist() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.AddToSet("my-key", "my-value"); + x.AddToSet("my-key", "my-value"); + }); + + var recordCount = sql.Query("select count(*) from HangFire.[Set]").Single(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void AddToSet_WithScore_AddsARecordWithScore_WhenBothKeyAndValueAreNotExist() + { + UseConnection(sql => + { + Commit(sql, x => x.AddToSet("my-key", "my-value", 3.2)); + + var record = sql.Query("select * from HangFire.[Set]").Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal("my-value", record.Value); + Assert.Equal(3.2, record.Score, 3); + }); + } + + [Fact, CleanDatabase] + public void AddToSet_WithScore_UpdatesAScore_WhenBothKeyAndValueAreExist() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.AddToSet("my-key", "my-value"); + x.AddToSet("my-key", "my-value", 3.2); + }); + + var record = sql.Query("select * from HangFire.[Set]").Single(); + + Assert.Equal(3.2, record.Score, 3); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromSet_RemovesARecord_WithGivenKeyAndValue() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.AddToSet("my-key", "my-value"); + x.RemoveFromSet("my-key", "my-value"); + }); + + var recordCount = sql.Query("select count(*) from HangFire.[Set]").Single(); + + Assert.Equal(0, recordCount); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromSet_DoesNotRemoveRecord_WithSameKey_AndDifferentValue() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.AddToSet("my-key", "my-value"); + x.RemoveFromSet("my-key", "different-value"); + }); + + var recordCount = sql.Query("select count(*) from HangFire.[Set]").Single(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromSet_DoesNotRemoveRecord_WithSameValue_AndDifferentKey() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.AddToSet("my-key", "my-value"); + x.RemoveFromSet("different-key", "my-value"); + }); + + var recordCount = sql.Query("select count(*) from HangFire.[Set]").Single(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void InsertToList_AddsARecord_WithGivenValues() + { + UseConnection(sql => + { + Commit(sql, x => x.InsertToList("my-key", "my-value")); + + var record = sql.Query("select * from HangFire.List").Single(); + + Assert.Equal("my-key", record.Key); + Assert.Equal("my-value", record.Value); + }); + } + + [Fact, CleanDatabase] + public void InsertToList_AddsAnotherRecord_WhenBothKeyAndValueAreExist() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.InsertToList("my-key", "my-value"); + x.InsertToList("my-key", "my-value"); + }); + + var recordCount = sql.Query("select count(*) from HangFire.List").Single(); + + Assert.Equal(2, recordCount); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromList_RemovesAllRecords_WithGivenKeyAndValue() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.InsertToList("my-key", "my-value"); + x.InsertToList("my-key", "my-value"); + x.RemoveFromList("my-key", "my-value"); + }); + + var recordCount = sql.Query("select count(*) from HangFire.List").Single(); + + Assert.Equal(0, recordCount); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromList_DoesNotRemoveRecords_WithSameKey_ButDifferentValue() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.InsertToList("my-key", "my-value"); + x.RemoveFromList("my-key", "different-value"); + }); + + var recordCount = sql.Query("select count(*) from HangFire.List").Single(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void RemoveFromList_DoesNotRemoveRecords_WithSameValue_ButDifferentKey() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.InsertToList("my-key", "my-value"); + x.RemoveFromList("different-key", "my-value"); + }); + + var recordCount = sql.Query("select count(*) from HangFire.List").Single(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void TrimList_TrimsAList_ToASpecifiedRange() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.InsertToList("my-key", "0"); + x.InsertToList("my-key", "1"); + x.InsertToList("my-key", "2"); + x.InsertToList("my-key", "3"); + x.TrimList("my-key", 1, 2); + }); + + var records = sql.Query("select * from HangFire.List").ToArray(); + + Assert.Equal(2, records.Length); + Assert.Equal("1", records[0].Value); + Assert.Equal("2", records[1].Value); + }); + } + + [Fact, CleanDatabase] + public void TrimList_RemovesRecordsToEnd_IfKeepAndingAt_GreaterThanMaxElementIndex() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.InsertToList("my-key", "0"); + x.InsertToList("my-key", "1"); + x.InsertToList("my-key", "2"); + x.TrimList("my-key", 1, 100); + }); + + var recordCount = sql.Query("select count(*) from HangFire.List").Single(); + + Assert.Equal(2, recordCount); + }); + } + + [Fact, CleanDatabase] + public void TrimList_RemovesAllRecords_WhenStartingFromValue_GreaterThanMaxElementIndex() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.InsertToList("my-key", "0"); + x.TrimList("my-key", 1, 100); + }); + + var recordCount = sql.Query("select count(*) from HangFire.List").Single(); + + Assert.Equal(0, recordCount); + }); + } + + [Fact, CleanDatabase] + public void TrimList_RemovesAllRecords_IfStartFromGreaterThanEndingAt() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.InsertToList("my-key", "0"); + x.TrimList("my-key", 1, 0); + }); + + var recordCount = sql.Query("select count(*) from HangFire.List").Single(); + + Assert.Equal(0, recordCount); + }); + } + + [Fact, CleanDatabase] + public void TrimList_RemovesRecords_OnlyOfAGivenKey() + { + UseConnection(sql => + { + Commit(sql, x => + { + x.InsertToList("my-key", "0"); + x.TrimList("another-key", 1, 0); + }); + + var recordCount = sql.Query("select count(*) from HangFire.List").Single(); + + Assert.Equal(1, recordCount); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(sql => + { + var exception = Assert.Throws( + () => Commit(sql, x => x.SetRangeInHash(null, new Dictionary()))); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_ThrowsAnException_WhenKeyValuePairsArgumentIsNull() + { + UseConnection(sql => + { + var exception = Assert.Throws( + () => Commit(sql, x => x.SetRangeInHash("some-hash", null))); + + Assert.Equal("keyValuePairs", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void SetRangeInHash_MergesAllRecords() + { + UseConnection(sql => + { + Commit(sql, x => x.SetRangeInHash("some-hash", new Dictionary + { + { "Key1", "Value1" }, + { "Key2", "Value2" } + })); + + var result = sql.Query( + "select * from HangFire.Hash where [Key] = @key", + new { key = "some-hash" }) + .ToDictionary(x => (string)x.Field, x => (string)x.Value); + + Assert.Equal("Value1", result["Key1"]); + Assert.Equal("Value2", result["Key2"]); + }); + } + + [Fact, CleanDatabase] + public void RemoveHash_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(sql => + { + Assert.Throws( + () => Commit(sql, x => x.RemoveHash(null))); + }); + } + + [Fact, CleanDatabase] + public void RemoveHash_RemovesAllHashRecords() + { + UseConnection(sql => + { + // Arrange + Commit(sql, x => x.SetRangeInHash("some-hash", new Dictionary + { + { "Key1", "Value1" }, + { "Key2", "Value2" } + })); + + // Act + Commit(sql, x => x.RemoveHash("some-hash")); + + // Assert + var count = sql.Query("select count(*) from HangFire.Hash").Single(); + Assert.Equal(0, count); + }); + } + + [Fact, CleanDatabase] + public void AddRangeToSet_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(sql => + { + var exception = Assert.Throws( + () => Commit(sql, x => x.AddRangeToSet(null, new List()))); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void AddRangeToSet_ThrowsAnException_WhenItemsValueIsNull() + { + UseConnection(sql => + { + var exception = Assert.Throws( + () => Commit(sql, x => x.AddRangeToSet("my-set", null))); + + Assert.Equal("items", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void AddRangeToSet_AddsAllItems_ToAGivenSet() + { + UseConnection(sql => + { + var items = new List { "1", "2", "3" }; + + Commit(sql, x => x.AddRangeToSet("my-set", items)); + + var records = sql.Query(@"select [Value] from HangFire.[Set] where [Key] = N'my-set'"); + Assert.Equal(items, records); + }); + } + + [Fact, CleanDatabase] + public void RemoveSet_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(sql => + { + Assert.Throws( + () => Commit(sql, x => x.RemoveSet(null))); + }); + } + + [Fact, CleanDatabase] + public void RemoveSet_RemovesASet_WithAGivenKey() + { + const string arrangeSql = @" +insert into HangFire.[Set] ([Key], [Value], [Score]) values (@key, @value, 0.0)"; + + UseConnection(sql => + { + sql.Execute(arrangeSql, new [] + { + new { key = "set-1", value = "1" }, + new { key = "set-2", value = "1" } + }); + + Commit(sql, x => x.RemoveSet("set-1")); + + var record = sql.Query("select * from HangFire.[Set]").Single(); + Assert.Equal("set-2", record.Key); + }); + } + + [Fact, CleanDatabase] + public void ExpireHash_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(sql => + { + var exception = Assert.Throws( + () => Commit(sql, x => x.ExpireHash(null, TimeSpan.FromMinutes(5)))); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void ExpireHash_SetsExpirationTimeOnAHash_WithGivenKey() + { + const string arrangeSql = @" +insert into HangFire.Hash ([Key], [Field]) +values (@key, @field)"; + + UseConnection(sql => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "hash-1", field = "field" }, + new { key = "hash-2", field = "field" } + }); + + // Act + Commit(sql, x => x.ExpireHash("hash-1", TimeSpan.FromMinutes(60))); + + // Assert + var records = sql.Query("select * from HangFire.Hash").ToDictionary(x => (string)x.Key, x => (DateTime?)x.ExpireAt); + Assert.True(DateTime.UtcNow.AddMinutes(59) < records["hash-1"]); + Assert.True(records["hash-1"] < DateTime.UtcNow.AddMinutes(61)); + Assert.Null(records["hash-2"]); + }); + } + + [Fact, CleanDatabase] + public void ExpireSet_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(sql => + { + var exception = Assert.Throws( + () => Commit(sql, x => x.ExpireSet(null, TimeSpan.FromSeconds(45)))); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void ExpireSet_SetsExpirationTime_OnASet_WithGivenKey() + { + const string arrangeSql = @" +insert into HangFire.[Set] ([Key], [Value], [Score]) +values (@key, @value, 0.0)"; + + UseConnection(sql => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "set-1", value = "1" }, + new { key = "set-2", value = "1" } + }); + + // Act + Commit(sql, x => x.ExpireSet("set-1", TimeSpan.FromMinutes(60))); + + // Assert + var records = sql.Query("select * from HangFire.[Set]").ToDictionary(x => (string)x.Key, x => (DateTime?)x.ExpireAt); + Assert.True(DateTime.UtcNow.AddMinutes(59) < records["set-1"]); + Assert.True(records["set-1"] < DateTime.UtcNow.AddMinutes(61)); + Assert.Null(records["set-2"]); + }); + } + + [Fact, CleanDatabase] + public void ExpireList_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(sql => + { + var exception = Assert.Throws( + () => Commit(sql, x => x.ExpireList(null, TimeSpan.FromSeconds(45)))); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void ExpireList_SetsExpirationTime_OnAList_WithGivenKey() + { + const string arrangeSql = @" +insert into HangFire.[List] ([Key]) values (@key)"; + + UseConnection(sql => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "list-1", value = "1" }, + new { key = "list-2", value = "1" } + }); + + // Act + Commit(sql, x => x.ExpireList("list-1", TimeSpan.FromMinutes(60))); + + // Assert + var records = sql.Query("select * from HangFire.[List]").ToDictionary(x => (string)x.Key, x => (DateTime?)x.ExpireAt); + Assert.True(DateTime.UtcNow.AddMinutes(59) < records["list-1"]); + Assert.True(records["list-1"] < DateTime.UtcNow.AddMinutes(61)); + Assert.Null(records["list-2"]); + }); + } + + [Fact, CleanDatabase] + public void PersistHash_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(sql => + { + var exception = Assert.Throws( + () => Commit(sql, x => x.PersistHash(null))); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void PersistHash_ClearsExpirationTime_OnAGivenHash() + { + const string arrangeSql = @" +insert into HangFire.Hash ([Key], [Field], [ExpireAt]) +values (@key, @field, @expireAt)"; + + UseConnection(sql => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "hash-1", field = "field", expireAt = DateTime.UtcNow.AddDays(1) }, + new { key = "hash-2", field = "field", expireAt = DateTime.UtcNow.AddDays(1) } + }); + + // Act + Commit(sql, x => x.PersistHash("hash-1")); + + // Assert + var records = sql.Query("select * from HangFire.Hash").ToDictionary(x => (string)x.Key, x => (DateTime?)x.ExpireAt); + Assert.Null(records["hash-1"]); + Assert.NotNull(records["hash-2"]); + }); + } + + [Fact, CleanDatabase] + public void PersistSet_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(sql => + { + var exception = Assert.Throws( + () => Commit(sql, x => x.PersistSet(null))); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void PersistSet_ClearsExpirationTime_OnAGivenHash() + { + const string arrangeSql = @" +insert into HangFire.[Set] ([Key], [Value], [ExpireAt], [Score]) +values (@key, @value, @expireAt, 0.0)"; + + UseConnection(sql => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "set-1", value = "1", expireAt = DateTime.UtcNow.AddDays(1) }, + new { key = "set-2", value = "1", expireAt = DateTime.UtcNow.AddDays(1) } + }); + + // Act + Commit(sql, x => x.PersistSet("set-1")); + + // Assert + var records = sql.Query("select * from HangFire.[Set]").ToDictionary(x => (string)x.Key, x => (DateTime?)x.ExpireAt); + Assert.Null(records["set-1"]); + Assert.NotNull(records["set-2"]); + }); + } + + [Fact, CleanDatabase] + public void PersistList_ThrowsAnException_WhenKeyIsNull() + { + UseConnection(sql => + { + var exception = Assert.Throws( + () => Commit(sql, x => x.PersistList(null))); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact, CleanDatabase] + public void PersistList_ClearsExpirationTime_OnAGivenHash() + { + const string arrangeSql = @" +insert into HangFire.[List] ([Key], [ExpireAt]) +values (@key, @expireAt)"; + + UseConnection(sql => + { + // Arrange + sql.Execute(arrangeSql, new[] + { + new { key = "list-1", expireAt = DateTime.UtcNow.AddDays(1) }, + new { key = "list-2", expireAt = DateTime.UtcNow.AddDays(1) } + }); + + // Act + Commit(sql, x => x.PersistList("list-1")); + + // Assert + var records = sql.Query("select * from HangFire.[List]").ToDictionary(x => (string)x.Key, x => (DateTime?)x.ExpireAt); + Assert.Null(records["list-1"]); + Assert.NotNull(records["list-2"]); + }); + } + + private static void UseConnection(Action action) + { + using (var connection = ConnectionUtils.CreateConnection()) + { + action(connection); + } + } + + private void Commit( + SqlConnection connection, + Action action) + { + var storage = new Mock(connection); + storage.Setup(x => x.QueueProviders).Returns(_queueProviders); + + using (var transaction = new SqlServerWriteOnlyTransaction(storage.Object)) + { + action(transaction); + transaction.Commit(); + } + } + } +} diff --git a/tests/Hangfire.SqlServer.Tests/StorageOptionsFacts.cs b/tests/Hangfire.SqlServer.Tests/StorageOptionsFacts.cs new file mode 100644 index 0000000..e2ff857 --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/StorageOptionsFacts.cs @@ -0,0 +1,43 @@ +using System; +using Xunit; + +namespace Hangfire.SqlServer.Tests +{ + public class StorageOptionsFacts + { + [Fact] + public void Ctor_SetsTheDefaultOptions() + { + var options = new SqlServerStorageOptions(); + + Assert.True(options.QueuePollInterval > TimeSpan.Zero); + Assert.True(options.InvisibilityTimeout > TimeSpan.Zero); + Assert.True(options.JobExpirationCheckInterval > TimeSpan.Zero); + Assert.True(options.PrepareSchemaIfNecessary); + } + + [Fact] + public void Set_QueuePollInterval_ShouldThrowAnException_WhenGivenIntervalIsEqualToZero() + { + var options = new SqlServerStorageOptions(); + Assert.Throws( + () => options.QueuePollInterval = TimeSpan.Zero); + } + + [Fact] + public void Set_QueuePollInterval_ShouldThrowAnException_WhenGivenIntervalIsNegative() + { + var options = new SqlServerStorageOptions(); + Assert.Throws( + () => options.QueuePollInterval = TimeSpan.FromSeconds(-1)); + } + + [Fact] + public void Set_QueuePollInterval_SetsTheValue() + { + var options = new SqlServerStorageOptions(); + options.QueuePollInterval = TimeSpan.FromSeconds(1); + Assert.Equal(TimeSpan.FromSeconds(1), options.QueuePollInterval); + } + } +} diff --git a/tests/Hangfire.SqlServer.Tests/Utils/CleanDatabaseAttribute.cs b/tests/Hangfire.SqlServer.Tests/Utils/CleanDatabaseAttribute.cs new file mode 100644 index 0000000..0bbe12e --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/Utils/CleanDatabaseAttribute.cs @@ -0,0 +1,25 @@ +using System; +using System.Data.SqlClient; +using System.Reflection; +using System.Threading; +using System.Transactions; +using Dapper; +using Xunit; + +namespace Hangfire.SqlServer.Tests +{ + public class CleanDatabaseAttribute : BeforeAfterTestAttribute + { + private static readonly object GlobalLock = new object(); + + public override void Before(MethodInfo methodUnderTest) + { + Monitor.Enter(GlobalLock); + } + + public override void After(MethodInfo methodUnderTest) + { + Monitor.Exit(GlobalLock); + } + } +} diff --git a/tests/Hangfire.SqlServer.Tests/Utils/ConnectionUtils.cs b/tests/Hangfire.SqlServer.Tests/Utils/ConnectionUtils.cs new file mode 100644 index 0000000..0e35beb --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/Utils/ConnectionUtils.cs @@ -0,0 +1,17 @@ +using System; +using System.Data.SqlClient; + +namespace Hangfire.SqlServer.Tests +{ + public static class ConnectionUtils + { + private const string DatabaseVariable = "Hangfire_SqlServer_DatabaseName"; + private const string ConnectionStringTemplateVariable + = "Hangfire_SqlServer_ConnectionStringTemplate"; + + private const string MasterDatabaseName = "master"; + private const string DefaultDatabaseName = @"Hangfire.Raven.Tests"; + private const string DefaultConnectionStringTemplate + = @"Server=.\sqlexpress;Database={0};Trusted_Connection=True;"; + } +} diff --git a/tests/Hangfire.SqlServer.Tests/packages.config b/tests/Hangfire.SqlServer.Tests/packages.config new file mode 100644 index 0000000..a6d6f8d --- /dev/null +++ b/tests/Hangfire.SqlServer.Tests/packages.config @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file