├── Hangfire_Redis_StackExchange.snk ├── Hangfire.Redis.Tests ├── Hangfire_Redis_Tests.snk ├── xunit.runner.json ├── Hangfire.Redis.Tests.csproj.DotSettings ├── Utils │ ├── StaticFakeJobs.cs │ ├── CleanRedisAttribute.cs │ └── RedisUtils.cs ├── RedisTest.cs ├── RedisStorageOptionsFacts.cs ├── Hangfire.Redis.Tests.csproj ├── ApplyStateContextMock.cs ├── ProcessingStateHandlerFacts.cs ├── DeletedStateHandlerFacts.cs ├── FailedStateHandlerFacts.cs ├── SucceededStateHandlerFacts.cs ├── RedisSubscriptionFacts.cs ├── RedisStorageFacts.cs ├── ExpiredJobsWatcherFacts.cs ├── RedisLockFacts.cs ├── FetchedJobsWatcherFacts.cs ├── RedisConnectionFacts.cs ├── RedisFetchedJobFacts.cs └── RedisWriteOnlyTransactionFacts.cs ├── Hangfire.Redis.StackExchange ├── FetchedJobsWatcher.cs ├── FetchedJobsWatcherOptions.cs ├── Hangfire_Redis_StackExchange.snk ├── Properties │ └── AssemblyInfo.cs ├── Hangfire.Redis.StackExchange.csproj.DotSettings ├── HangfireSubscriber.cs ├── HangFireRedisException.cs ├── HangfireRedisTransactionException.cs ├── FailedStateHandler.cs ├── ProcessingStateHandler.cs ├── RedisSubscription.cs ├── SucceededStateHandler.cs ├── DeletedStateHandler.cs ├── RedisDatabaseExtensions.cs ├── RedisStorageExtensions.cs ├── RedisStorageOptions.cs ├── ExpiredJobsWatcher.cs ├── RedisFetchedJob.cs ├── RedisInfoKeys.cs ├── Hangfire.Redis.StackExchange.csproj ├── RedisLock.cs ├── RedisStorage.cs ├── RedisWriteDirectlyToDatabase.cs ├── RedisWriteOnlyTransaction.cs └── RedisConnection.cs ├── Hangfire.Redis.StackExchange.sln.DotSettings ├── License.md ├── .travis.yml ├── appveyor.yml ├── Hangfire.Redis.StackExchange.sln ├── .gitattributes ├── .gitignore └── README.md /Hangfire_Redis_StackExchange.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcoCasamento/Hangfire.Redis.StackExchange/HEAD/Hangfire_Redis_StackExchange.snk -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/Hangfire_Redis_Tests.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcoCasamento/Hangfire.Redis.StackExchange/HEAD/Hangfire.Redis.Tests/Hangfire_Redis_Tests.snk -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/FetchedJobsWatcher.cs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcoCasamento/Hangfire.Redis.StackExchange/HEAD/Hangfire.Redis.StackExchange/FetchedJobsWatcher.cs -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/FetchedJobsWatcherOptions.cs: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcoCasamento/Hangfire.Redis.StackExchange/HEAD/Hangfire.Redis.StackExchange/FetchedJobsWatcherOptions.cs -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/Hangfire_Redis_StackExchange.snk: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/marcoCasamento/Hangfire.Redis.StackExchange/HEAD/Hangfire.Redis.StackExchange/Hangfire_Redis_StackExchange.snk -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/xunit.runner.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://raw.githubusercontent.com/SchemaStore/schemastore/master/src/schemas/json/xunit.runner.schema.json", 3 | "parallelizeTestCollections": false 4 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange.sln.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | True -------------------------------------------------------------------------------- /License.md: -------------------------------------------------------------------------------- 1 | License 2 | ======== 3 | 4 | Copyright © 2015 Marco Casamento. 5 | Original Version of the product by Sergey Odinokov available at https://github.com/HangfireIO/Hangfire.Redis/ 6 | 7 | Hangfire.Redis.StackExchange is licensed under the terms of the LGPLv3 license, that is available at http://www.gnu.org/licenses/lgpl-3.0.html. 8 | It also makes use of https://github.com/StackExchange/StackExchange.Redis licensed under his own terms (MIT) 9 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/Hangfire.Redis.Tests.csproj.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | Preview -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/Properties/AssemblyInfo.cs: -------------------------------------------------------------------------------- 1 | using System.Runtime.CompilerServices; 2 | 3 | // General Information about an assembly is controlled through the following 4 | // set of attributes. Change these attribute values to modify the information 5 | // associated with an assembly. 6 | [assembly: InternalsVisibleTo("Hangfire.Redis.Tests")] 7 | 8 | // Allow the generation of mocks for internal types 9 | //[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/Hangfire.Redis.StackExchange.csproj.DotSettings: -------------------------------------------------------------------------------- 1 | 2 | Latest -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/Utils/StaticFakeJobs.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | 4 | namespace Hangfire.Redis.Tests.Utils 5 | { 6 | public static class StaticFakeJobs 7 | { 8 | public static string Work(int identifier, int waitTime) 9 | { 10 | Thread.Sleep(waitTime); 11 | var jobResult = String.Format("{0} - {1} Job done after waiting {2} ms", DateTime.Now.ToString("hh:mm:ss fff"), identifier, waitTime); 12 | Console.WriteLine(jobResult); 13 | return jobResult; 14 | } 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/Utils/CleanRedisAttribute.cs: -------------------------------------------------------------------------------- 1 | using System.Reflection; 2 | using Xunit.Sdk; 3 | 4 | namespace Hangfire.Redis.Tests.Utils 5 | { 6 | public class CleanRedisAttribute : BeforeAfterTestAttribute 7 | { 8 | public override void Before(MethodInfo methodUnderTest) 9 | { 10 | var client = RedisUtils.CreateClient(); 11 | client.Multiplexer.GetServer(RedisUtils.GetHostAndPort()).FlushDatabase(RedisUtils.GetDb()); 12 | } 13 | 14 | public override void After(MethodInfo methodUnderTest) 15 | { 16 | } 17 | } 18 | } -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/RedisTest.cs: -------------------------------------------------------------------------------- 1 | using Hangfire.Redis.Tests.Utils; 2 | using StackExchange.Redis; 3 | using Xunit; 4 | 5 | namespace Hangfire.Redis.Tests 6 | { 7 | [Collection("Sequential")] 8 | public class RedisTest 9 | { 10 | private readonly IDatabase _redis; 11 | 12 | public RedisTest() 13 | { 14 | _redis = RedisUtils.CreateClient(); 15 | } 16 | 17 | 18 | [Fact, CleanRedis] 19 | public void RedisSampleTest() 20 | { 21 | var defaultValue = _redis.StringGet("samplekey"); 22 | Assert.True(defaultValue.IsNull); 23 | } 24 | } 25 | } -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/RedisStorageOptionsFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Redis.StackExchange; 3 | using Xunit; 4 | 5 | namespace Hangfire.Redis.Tests 6 | { 7 | [Collection("Sequential")] 8 | public class RedisStorageOptionsFacts 9 | { 10 | 11 | [Fact] 12 | public void InvisibilityTimeout_HasDefaultValue() 13 | { 14 | var options = CreateOptions(); 15 | Assert.Equal(TimeSpan.FromMinutes(30), options.InvisibilityTimeout); 16 | } 17 | 18 | private static RedisStorageOptions CreateOptions() 19 | { 20 | return new RedisStorageOptions(); 21 | } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | services: 3 | - redis-server 4 | language: csharp 5 | solution: Hangfire.Redis.StackExchange.sln 6 | install: 7 | - nuget restore Hangfire.Redis.StackExchange.sln -Verbosity detailed 8 | - nuget install xunit.runner.console -OutputDirectory testrunner 9 | script: 10 | - xbuild /p:Configuration=Release Hangfire.Redis.StackExchange.sln 11 | - cp -av ./testrunner/xunit.runner.console.2.0.0/tools/* ./Hangfire.Redis.Tests/bin/Release/ 12 | - cp ./StackExchange.Redis/StackExchange.Redis/bin/Release/StackExchange.Redis.dll ./Hangfire.Redis.Tests/bin/Release/ 13 | - mono ./Hangfire.Redis.Tests/bin/Release/xunit.console.x86.exe ./Hangfire.Redis.Tests/bin/Release/Hangfire.Redis.Tests.dll -parallel none -maxthreads 1 14 | -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/HangfireSubscriber.cs: -------------------------------------------------------------------------------- 1 | using Hangfire.Server; 2 | using System; 3 | using System.Collections.Generic; 4 | using System.Text; 5 | using System.Threading; 6 | 7 | namespace Hangfire.Redis.StackExchange 8 | { 9 | /// 10 | /// Singelton used to keep track of hangfire jobs 11 | /// 12 | /// Came from https://github.com/AnderssonPeter/Hangfire.Console.Extensions/blob/master/Hangfire.Console.Extensions/HangfireSubscriber.cs 13 | internal class HangfireSubscriber : IServerFilter 14 | { 15 | private static readonly AsyncLocal localStorage = new AsyncLocal(); 16 | 17 | public static PerformingContext Value => localStorage.Value; 18 | 19 | public void OnPerforming(PerformingContext filterContext) 20 | { 21 | localStorage.Value = filterContext; 22 | } 23 | 24 | public void OnPerformed(PerformedContext filterContext) 25 | { 26 | localStorage.Value = null; 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/HangFireRedisException.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | 19 | namespace Hangfire.Redis.StackExchange 20 | { 21 | public class HangFireRedisException : Exception 22 | { 23 | public HangFireRedisException(string message) : base(message) 24 | { 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/HangfireRedisTransactionException.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | 19 | namespace Hangfire.Redis.StackExchange 20 | { 21 | public class HangfireRedisTransactionException : Exception 22 | { 23 | public HangfireRedisTransactionException(string message) : base(message) 24 | { 25 | } 26 | } 27 | } -------------------------------------------------------------------------------- /appveyor.yml: -------------------------------------------------------------------------------- 1 | version: 1.9.0.{build} 2 | branches: 3 | only: 4 | - master 5 | image: Visual Studio 2022 6 | configuration: Release 7 | platform: Any CPU 8 | 9 | install: 10 | - cmd: nuget install redis-64 -excludeversion 11 | - cmd: redis-64\tools\redis-server.exe --service-install 12 | - cmd: redis-64\tools\redis-server.exe --service-start 13 | before_build: 14 | - dotnet restore 15 | build: 16 | project: Hangfire.Redis.StackExchange.sln 17 | publish_nuget: true 18 | publish_nuget_symbols: true 19 | verbosity: minimal 20 | artifacts: 21 | - path: .\**\Hangfire.Redis.StackExchange.nupkg 22 | assembly_info: 23 | patch: true 24 | file: '**\AssemblyInfo.*' 25 | assembly_version: '{version}' 26 | assembly_file_version: '{version}' 27 | assembly_informational_version: '{version}' 28 | nuget: 29 | account_feed: false 30 | project_feed: false 31 | test_script: 32 | - dotnet test ".\Hangfire.Redis.Tests\Hangfire.Redis.Tests.csproj" 33 | #before_package: 34 | #- ps: nuget pack .\Hangfire.Redis.StackExchange\HangFire.Redis.StackExchange.nuspec -Version $env:APPVEYOR_BUILD_VERSION 35 | #deploy: 36 | # provider: NuGet 37 | # api_key: 38 | # secure: hiLTKk/ItMBgRI5HCemg6kARhcAGs6/V/eQpymZAdwQOtHgD8waLBtb3VMdgPRYG 39 | # artifact: /.*\.nupkg/ 40 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/Hangfire.Redis.Tests.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | net9.0 5 | false 6 | Hangfire_Redis_Tests.snk 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | all 16 | runtime; build; native; contentfiles; analyzers; buildtransitive 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | disable 30 | 31 | 32 | 33 | 34 | PreserveNewest 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/ApplyStateContextMock.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.States; 3 | using Moq; 4 | using Hangfire.Common; 5 | using Hangfire.Redis.StackExchange; 6 | using Hangfire.Redis.Tests.Utils; 7 | 8 | namespace Hangfire.Redis.Tests 9 | { 10 | public class ApplyStateContextMock 11 | { 12 | private readonly Lazy _context; 13 | 14 | public ApplyStateContextMock(string jobId) 15 | { 16 | NewStateValue = new Mock().Object; 17 | OldStateValue = null; 18 | var storage = CreateStorage(); 19 | var connection = storage.GetConnection(); 20 | var writeOnlyTransaction = connection.CreateWriteTransaction(); 21 | 22 | var job = new Job(this.GetType().GetMethod("GetType")); 23 | var backgroundJob = new BackgroundJob(jobId, job, DateTime.MinValue); 24 | 25 | _context = new Lazy( 26 | () => new ApplyStateContext( 27 | storage, connection, writeOnlyTransaction, backgroundJob, 28 | NewStateValue, 29 | OldStateValue)); 30 | } 31 | 32 | public IState NewStateValue { get; set; } 33 | public string OldStateValue { get; set; } 34 | 35 | public ApplyStateContext Object => _context.Value; 36 | 37 | private RedisStorage CreateStorage() 38 | { 39 | var options = new RedisStorageOptions() { Db = RedisUtils.GetDb() }; 40 | return new RedisStorage(RedisUtils.GetHostAndPort(), options); 41 | } 42 | 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/ProcessingStateHandlerFacts.cs: -------------------------------------------------------------------------------- 1 | using Hangfire.Redis.StackExchange; 2 | using Hangfire.Redis.Tests.Utils; 3 | using Hangfire.States; 4 | using Hangfire.Storage; 5 | using Moq; 6 | using Xunit; 7 | 8 | namespace Hangfire.Redis.Tests 9 | { 10 | [CleanRedis, Collection("Sequential")] 11 | public class ProcessingStateHandlerFacts 12 | { 13 | private const string JobId = "1"; 14 | 15 | private readonly ApplyStateContextMock _context; 16 | private readonly Mock _transaction; 17 | 18 | public ProcessingStateHandlerFacts() 19 | { 20 | _context = new ApplyStateContextMock(JobId); 21 | _transaction = new Mock(); 22 | } 23 | 24 | [Fact] 25 | public void StateName_ShouldBeEqualToProcessingState() 26 | { 27 | var handler = new ProcessingStateHandler(); 28 | Assert.Equal(ProcessingState.StateName, handler.StateName); 29 | } 30 | 31 | [Fact] 32 | public void Apply_ShouldAddTheJob_ToTheProcessingSet() 33 | { 34 | var handler = new ProcessingStateHandler(); 35 | handler.Apply(_context.Object, _transaction.Object); 36 | 37 | _transaction.Verify(x => x.AddToSet( 38 | "processing", JobId, It.IsAny())); 39 | } 40 | 41 | [Fact] 42 | public void Unapply_ShouldRemoveTheJob_FromTheProcessingSet() 43 | { 44 | var handler = new ProcessingStateHandler(); 45 | handler.Unapply(_context.Object, _transaction.Object); 46 | 47 | _transaction.Verify(x => x.RemoveFromSet("processing", JobId)); 48 | } 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/FailedStateHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | using Hangfire.Common; 19 | using Hangfire.States; 20 | using Hangfire.Storage; 21 | 22 | namespace Hangfire.Redis.StackExchange 23 | { 24 | internal class FailedStateHandler : IStateHandler 25 | { 26 | public void Apply(ApplyStateContext context, IWriteOnlyTransaction transaction) 27 | { 28 | transaction.AddToSet( 29 | "failed", 30 | context.BackgroundJob.Id, 31 | JobHelper.ToTimestamp(DateTime.UtcNow)); 32 | } 33 | 34 | public void Unapply(ApplyStateContext context, IWriteOnlyTransaction transaction) 35 | { 36 | transaction.RemoveFromSet("failed", context.BackgroundJob.Id); 37 | } 38 | 39 | public string StateName 40 | { 41 | get { return FailedState.StateName; } 42 | } 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/ProcessingStateHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | using Hangfire.Common; 19 | using Hangfire.States; 20 | using Hangfire.Storage; 21 | 22 | namespace Hangfire.Redis.StackExchange 23 | { 24 | internal class ProcessingStateHandler : IStateHandler 25 | { 26 | public void Apply(ApplyStateContext context, IWriteOnlyTransaction transaction) 27 | { 28 | transaction.AddToSet( 29 | "processing", 30 | context.BackgroundJob.Id, 31 | JobHelper.ToTimestamp(DateTime.UtcNow)); 32 | } 33 | 34 | public void Unapply(ApplyStateContext context, IWriteOnlyTransaction transaction) 35 | { 36 | transaction.RemoveFromSet("processing", context.BackgroundJob.Id); 37 | } 38 | 39 | public string StateName 40 | { 41 | get { return ProcessingState.StateName; } 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/DeletedStateHandlerFacts.cs: -------------------------------------------------------------------------------- 1 | using Hangfire.Redis.StackExchange; 2 | using Hangfire.Redis.Tests.Utils; 3 | using Hangfire.States; 4 | using Hangfire.Storage; 5 | using Moq; 6 | using Xunit; 7 | 8 | namespace Hangfire.Redis.Tests 9 | { 10 | [CleanRedis, Collection("Sequential")] 11 | public class DeletedStateHandlerFacts 12 | { 13 | private const string JobId = "1"; 14 | 15 | private readonly ApplyStateContextMock _context; 16 | private readonly Mock _transaction; 17 | 18 | public DeletedStateHandlerFacts() 19 | { 20 | _context = new ApplyStateContextMock(JobId); 21 | _transaction = new Mock(); 22 | } 23 | 24 | [Fact] 25 | public void StateName_ShouldBeEqualToSucceededState() 26 | { 27 | var handler = new DeletedStateHandler(); 28 | Assert.Equal(DeletedState.StateName, handler.StateName); 29 | } 30 | 31 | [Fact] 32 | public void Apply_ShouldInsertTheJob_ToTheBeginningOfTheSucceededList_AndTrimIt() 33 | { 34 | var handler = new DeletedStateHandler(); 35 | handler.Apply(_context.Object, _transaction.Object); 36 | 37 | _transaction.Verify(x => x.InsertToList( 38 | "deleted", JobId)); 39 | _transaction.Verify(x => x.TrimList( 40 | "deleted", 0, 499)); 41 | } 42 | 43 | [Fact] 44 | public void Unapply_ShouldRemoveTheJob_FromTheSucceededList() 45 | { 46 | var handler = new DeletedStateHandler(); 47 | handler.Unapply(_context.Object, _transaction.Object); 48 | 49 | _transaction.Verify(x => x.RemoveFromList("deleted", JobId)); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/FailedStateHandlerFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Redis.StackExchange; 3 | using Hangfire.Redis.Tests.Utils; 4 | using Hangfire.States; 5 | using Hangfire.Storage; 6 | using Moq; 7 | using Xunit; 8 | 9 | namespace Hangfire.Redis.Tests 10 | { 11 | [CleanRedis, Collection("Sequential")] 12 | public class FailedStateHandlerFacts 13 | { 14 | private const string JobId = "1"; 15 | 16 | private readonly ApplyStateContextMock _context; 17 | private readonly Mock _transaction; 18 | 19 | public FailedStateHandlerFacts() 20 | { 21 | _context = new ApplyStateContextMock(JobId) 22 | { 23 | NewStateValue = new FailedState(new InvalidOperationException()) 24 | }; 25 | 26 | _transaction = new Mock(); 27 | } 28 | 29 | [Fact] 30 | public void StateName_ShouldBeEqualToFailedState() 31 | { 32 | var handler = new FailedStateHandler(); 33 | Assert.Equal(FailedState.StateName, handler.StateName); 34 | } 35 | 36 | [Fact] 37 | public void Apply_ShouldAddTheJob_ToTheFailedSet() 38 | { 39 | var handler = new FailedStateHandler(); 40 | handler.Apply(_context.Object, _transaction.Object); 41 | 42 | _transaction.Verify(x => x.AddToSet( 43 | "failed", JobId, It.IsAny())); 44 | } 45 | 46 | [Fact] 47 | public void Unapply_ShouldRemoveTheJob_FromTheFailedSet() 48 | { 49 | var handler = new FailedStateHandler(); 50 | handler.Unapply(_context.Object, _transaction.Object); 51 | 52 | _transaction.Verify(x => x.RemoveFromSet("failed", JobId)); 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/SucceededStateHandlerFacts.cs: -------------------------------------------------------------------------------- 1 | using Hangfire.Redis.StackExchange; 2 | using Hangfire.Redis.Tests.Utils; 3 | using Hangfire.States; 4 | using Hangfire.Storage; 5 | using Moq; 6 | using Xunit; 7 | 8 | namespace Hangfire.Redis.Tests 9 | { 10 | [CleanRedis, Collection("Sequential")] 11 | public class SucceededStateHandlerFacts 12 | { 13 | private const string JobId = "1"; 14 | 15 | private readonly ApplyStateContextMock _context; 16 | private readonly Mock _transaction; 17 | 18 | public SucceededStateHandlerFacts() 19 | { 20 | _context = new ApplyStateContextMock(JobId); 21 | _transaction = new Mock(); 22 | } 23 | 24 | [Fact] 25 | public void StateName_ShouldBeEqualToSucceededState() 26 | { 27 | var handler = new SucceededStateHandler(); 28 | Assert.Equal(SucceededState.StateName, handler.StateName); 29 | } 30 | 31 | [Fact] 32 | public void Apply_ShouldInsertTheJob_ToTheBeginningOfTheSucceededList_AndTrimIt() 33 | { 34 | var handler = new SucceededStateHandler(); 35 | handler.Apply(_context.Object, _transaction.Object); 36 | 37 | _transaction.Verify(x => x.InsertToList( 38 | "succeeded", JobId)); 39 | _transaction.Verify(x => x.TrimList( 40 | "succeeded", 0, 499)); 41 | } 42 | 43 | [Fact] 44 | public void Unapply_ShouldRemoveTheJob_FromTheSucceededList() 45 | { 46 | var handler = new SucceededStateHandler(); 47 | handler.Unapply(_context.Object, _transaction.Object); 48 | 49 | _transaction.Verify(x => x.RemoveFromList("succeeded", JobId)); 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/RedisSubscription.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using Hangfire.Annotations; 4 | using Hangfire.Server; 5 | using StackExchange.Redis; 6 | 7 | namespace Hangfire.Redis.StackExchange 8 | { 9 | #pragma warning disable 618 10 | internal class RedisSubscription : IServerComponent 11 | #pragma warning restore 618 12 | { 13 | private readonly ManualResetEvent _mre = new ManualResetEvent(false); 14 | private readonly RedisStorage _storage; 15 | private readonly ISubscriber _subscriber; 16 | 17 | public RedisSubscription([NotNull] RedisStorage storage, [NotNull] ISubscriber subscriber) 18 | { 19 | _storage = storage ?? throw new ArgumentNullException(nameof(storage)); 20 | Channel = new RedisChannel(_storage.GetRedisKey("JobFetchChannel"), RedisChannel.PatternMode.Literal); 21 | _subscriber = subscriber ?? throw new ArgumentNullException(nameof(subscriber)); 22 | 23 | } 24 | 25 | public RedisChannel Channel { get; } 26 | 27 | public void WaitForJob(TimeSpan timeout, CancellationToken cancellationToken) 28 | { 29 | _mre.Reset(); 30 | WaitHandle.WaitAny(new[] {_mre, cancellationToken.WaitHandle}, timeout); 31 | } 32 | 33 | void IServerComponent.Execute(CancellationToken cancellationToken) 34 | { 35 | _subscriber.Subscribe(Channel, (channel, value) => _mre.Set()); 36 | cancellationToken.WaitHandle.WaitOne(); 37 | 38 | if (cancellationToken.IsCancellationRequested) 39 | { 40 | _subscriber.Unsubscribe(Channel); 41 | _mre.Reset(); 42 | } 43 | } 44 | 45 | ~RedisSubscription() 46 | { 47 | _mre.Dispose(); 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/SucceededStateHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using Hangfire.States; 18 | using Hangfire.Storage; 19 | 20 | namespace Hangfire.Redis.StackExchange 21 | { 22 | internal class SucceededStateHandler : IStateHandler 23 | { 24 | public void Apply(ApplyStateContext context, IWriteOnlyTransaction transaction) 25 | { 26 | transaction.InsertToList("succeeded", context.BackgroundJob.Id); 27 | 28 | var storage = context.Storage as RedisStorage; 29 | if (storage != null && storage.SucceededListSize > 0) 30 | { 31 | transaction.TrimList("succeeded", 0, storage.SucceededListSize); 32 | } 33 | } 34 | 35 | public void Unapply(ApplyStateContext context, IWriteOnlyTransaction transaction) 36 | { 37 | transaction.RemoveFromList("succeeded", context.BackgroundJob.Id); 38 | } 39 | 40 | public string StateName => SucceededState.StateName; 41 | } 42 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/DeletedStateHandler.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using Hangfire.States; 18 | using Hangfire.Storage; 19 | 20 | namespace Hangfire.Redis.StackExchange 21 | { 22 | internal class DeletedStateHandler : IStateHandler 23 | { 24 | public void Apply(ApplyStateContext context, IWriteOnlyTransaction transaction) 25 | { 26 | transaction.InsertToList("deleted", context.BackgroundJob.Id); 27 | 28 | var storage = context.Storage as RedisStorage; 29 | if (storage != null && storage.DeletedListSize > 0) 30 | { 31 | transaction.TrimList("deleted", 0, storage.DeletedListSize); 32 | } 33 | } 34 | 35 | public void Unapply(ApplyStateContext context, IWriteOnlyTransaction transaction) 36 | { 37 | transaction.RemoveFromList("deleted", context.BackgroundJob.Id); 38 | } 39 | 40 | public string StateName 41 | { 42 | get { return DeletedState.StateName; } 43 | } 44 | } 45 | } -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/RedisSubscriptionFacts.cs: -------------------------------------------------------------------------------- 1 | using Moq; 2 | using StackExchange.Redis; 3 | using System; 4 | using System.Diagnostics; 5 | using System.Threading; 6 | using Hangfire.Redis.StackExchange; 7 | using Hangfire.Redis.Tests.Utils; 8 | using Xunit; 9 | 10 | namespace Hangfire.Redis.Tests 11 | { 12 | [CleanRedis, Collection("Sequential")] 13 | public class RedisSubscriptionFacts 14 | { 15 | private readonly CancellationTokenSource _cts; 16 | private readonly RedisStorage _storage; 17 | private readonly Mock _subscriber; 18 | 19 | public RedisSubscriptionFacts() 20 | { 21 | _cts = new CancellationTokenSource(); 22 | 23 | var options = new RedisStorageOptions() { Db = RedisUtils.GetDb() }; 24 | _storage = new RedisStorage(RedisUtils.GetHostAndPort(), options); 25 | 26 | _subscriber = new Mock(); 27 | } 28 | 29 | [Fact] 30 | public void Ctor_ThrowAnException_WhenStorageIsNull() 31 | { 32 | Assert.Throws("storage", 33 | () => new RedisSubscription(null, _subscriber.Object)); 34 | } 35 | 36 | [Fact] 37 | public void Ctor_ThrowAnException_WhenSubscriberIsNull() 38 | { 39 | Assert.Throws("subscriber", 40 | () => new RedisSubscription(_storage, null)); 41 | } 42 | [Fact] 43 | public void WaitForJob_WaitForTheTimeout() 44 | { 45 | //Arrange 46 | Stopwatch sw = new Stopwatch(); 47 | var subscription = new RedisSubscription(_storage, RedisUtils.CreateSubscriber()); 48 | var timeout = TimeSpan.FromMilliseconds(100); 49 | sw.Start(); 50 | 51 | //Act 52 | subscription.WaitForJob(timeout, _cts.Token); 53 | 54 | //Assert 55 | sw.Stop(); 56 | Assert.InRange(sw.ElapsedMilliseconds, 99, 120); 57 | } 58 | } 59 | 60 | } 61 | -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange.sln: -------------------------------------------------------------------------------- 1 | 2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | # Visual Studio Version 17 4 | VisualStudioVersion = 17.6.33417.168 5 | MinimumVisualStudioVersion = 10.0.40219.1 6 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hangfire.Redis.StackExchange", "Hangfire.Redis.StackExchange\Hangfire.Redis.StackExchange.csproj", "{830696FE-FBDF-498C-AF5C-98B1E807AE0D}" 7 | EndProject 8 | Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hangfire.Redis.Tests", "Hangfire.Redis.Tests\Hangfire.Redis.Tests.csproj", "{E8031950-A2BD-48C1-966F-7B9E3DC37631}" 9 | EndProject 10 | Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Project Items", "Project Items", "{EC2121D8-8CDD-4B1E-8BFF-57402A338C4F}" 11 | ProjectSection(SolutionItems) = preProject 12 | appveyor.yml = appveyor.yml 13 | License.md = License.md 14 | README.md = README.md 15 | EndProjectSection 16 | EndProject 17 | Global 18 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 19 | Debug|Any CPU = Debug|Any CPU 20 | Release|Any CPU = Release|Any CPU 21 | EndGlobalSection 22 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 23 | {830696FE-FBDF-498C-AF5C-98B1E807AE0D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 24 | {830696FE-FBDF-498C-AF5C-98B1E807AE0D}.Debug|Any CPU.Build.0 = Debug|Any CPU 25 | {830696FE-FBDF-498C-AF5C-98B1E807AE0D}.Release|Any CPU.ActiveCfg = Release|Any CPU 26 | {830696FE-FBDF-498C-AF5C-98B1E807AE0D}.Release|Any CPU.Build.0 = Release|Any CPU 27 | {E8031950-A2BD-48C1-966F-7B9E3DC37631}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 28 | {E8031950-A2BD-48C1-966F-7B9E3DC37631}.Debug|Any CPU.Build.0 = Debug|Any CPU 29 | {E8031950-A2BD-48C1-966F-7B9E3DC37631}.Release|Any CPU.ActiveCfg = Release|Any CPU 30 | {E8031950-A2BD-48C1-966F-7B9E3DC37631}.Release|Any CPU.Build.0 = Release|Any CPU 31 | EndGlobalSection 32 | GlobalSection(SolutionProperties) = preSolution 33 | HideSolutionNode = FALSE 34 | EndGlobalSection 35 | GlobalSection(ExtensibilityGlobals) = postSolution 36 | SolutionGuid = {FD437583-D2B4-4448-AF6E-F63E10906151} 37 | EndGlobalSection 38 | EndGlobal 39 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/Utils/RedisUtils.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using StackExchange.Redis; 3 | 4 | namespace Hangfire.Redis.Tests.Utils 5 | { 6 | public static class RedisUtils 7 | { 8 | private const string HostVariable = "Hangfire_Redis_Host"; 9 | private const string PortVariable = "Hangfire_Redis_Port"; 10 | private const string DbVariable = "Hangfire_Redis_Db"; 11 | 12 | private const string DefaultHost = "127.0.0.1"; 13 | private const int DefaultPort = 6379; 14 | private const int DefaultDb = 1; 15 | static Lazy connection = null; 16 | 17 | static RedisUtils() 18 | { 19 | connection = new Lazy(() => 20 | { 21 | ConfigurationOptions options = new ConfigurationOptions 22 | { 23 | AllowAdmin = true, 24 | SyncTimeout = 15000, 25 | ConnectRetry = 5 26 | }; 27 | options.EndPoints.Add(GetHostAndPort()); 28 | return ConnectionMultiplexer.Connect(options); 29 | } 30 | ); 31 | } 32 | public static IServer GetFirstServer() 33 | { 34 | return connection.Value.GetServer(connection.Value.GetEndPoints()[0]); 35 | } 36 | public static IDatabase CreateClient() 37 | { 38 | return connection.Value.GetDatabase(DefaultDb); 39 | } 40 | 41 | public static ISubscriber CreateSubscriber() 42 | { 43 | return connection.Value.GetSubscriber(); 44 | } 45 | 46 | public static string GetHostAndPort() 47 | { 48 | return String.Format("{0}:{1}", GetHost(), GetPort()); 49 | } 50 | 51 | public static string GetHost() 52 | { 53 | return Environment.GetEnvironmentVariable(HostVariable) 54 | ?? DefaultHost; 55 | } 56 | 57 | public static int GetPort() 58 | { 59 | var portValue = Environment.GetEnvironmentVariable(PortVariable); 60 | return portValue != null ? int.Parse(portValue) : DefaultPort; 61 | } 62 | 63 | public static int GetDb() 64 | { 65 | var dbValue = Environment.GetEnvironmentVariable(DbVariable); 66 | return dbValue != null ? int.Parse(dbValue) : DefaultDb; 67 | } 68 | } 69 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/RedisDatabaseExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | using System.Collections.Generic; 19 | using System.Linq; 20 | using StackExchange.Redis; 21 | 22 | namespace Hangfire.Redis.StackExchange 23 | { 24 | public static class RedisDatabaseExtensions 25 | { 26 | public static HashEntry[] ToHashEntries(this IEnumerable> keyValuePairs) 27 | { 28 | var hashEntry = new HashEntry[keyValuePairs.Count()]; 29 | int i = 0; 30 | foreach (var kvp in keyValuePairs) 31 | { 32 | hashEntry[i] = new HashEntry(kvp.Key, kvp.Value); 33 | i++; 34 | } 35 | return hashEntry; 36 | } 37 | 38 | public static RedisValue[] ToRedisValues(this IEnumerable values) 39 | { 40 | if (values == null) 41 | throw new ArgumentNullException(nameof(values)); 42 | 43 | return values.Select(x => (RedisValue)x).ToArray(); 44 | } 45 | 46 | //public static Dictionary ToStringDictionary(this HashEntry[] entries) 47 | //{ 48 | // var dictionary = new Dictionary(entries.Length); 49 | // foreach (var entry in entries) 50 | // dictionary[entry.Name] = entry.Value; 51 | // return dictionary; 52 | //} 53 | 54 | public static Dictionary GetValuesMap(this IDatabase redis, string[] keys) 55 | { 56 | var redisKeyArr = keys.Select(x => (RedisKey)x).ToArray(); 57 | var valuesArr = redis.StringGet(redisKeyArr); 58 | Dictionary result = new Dictionary(valuesArr.Length); 59 | for (int i = 0; i < valuesArr.Length; i++) 60 | { 61 | result.Add(redisKeyArr[i], valuesArr[i]); 62 | } 63 | return result; 64 | } 65 | 66 | 67 | } 68 | 69 | } 70 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | ############################################################################### 2 | # Set default behavior to automatically normalize line endings. 3 | ############################################################################### 4 | * text=auto 5 | 6 | ############################################################################### 7 | # Set default behavior for command prompt diff. 8 | # 9 | # This is need for earlier builds of msysgit that does not have it on by 10 | # default for csharp files. 11 | # Note: This is only used by command line 12 | ############################################################################### 13 | #*.cs diff=csharp 14 | 15 | ############################################################################### 16 | # Set the merge driver for project and solution files 17 | # 18 | # Merging from the command prompt will add diff markers to the files if there 19 | # are conflicts (Merging from VS is not affected by the settings below, in VS 20 | # the diff markers are never inserted). Diff markers may cause the following 21 | # file extensions to fail to load in VS. An alternative would be to treat 22 | # these files as binary and thus will always conflict and require user 23 | # intervention with every merge. To do so, just uncomment the entries below 24 | ############################################################################### 25 | #*.sln merge=binary 26 | #*.csproj merge=binary 27 | #*.vbproj merge=binary 28 | #*.vcxproj merge=binary 29 | #*.vcproj merge=binary 30 | #*.dbproj merge=binary 31 | #*.fsproj merge=binary 32 | #*.lsproj merge=binary 33 | #*.wixproj merge=binary 34 | #*.modelproj merge=binary 35 | #*.sqlproj merge=binary 36 | #*.wwaproj merge=binary 37 | 38 | ############################################################################### 39 | # behavior for image files 40 | # 41 | # image files are treated as binary by default. 42 | ############################################################################### 43 | #*.jpg binary 44 | #*.png binary 45 | #*.gif binary 46 | 47 | ############################################################################### 48 | # diff behavior for common document formats 49 | # 50 | # Convert binary document formats to text before diffing them. This feature 51 | # is only available from the command line. Turn it on by uncommenting the 52 | # entries below. 53 | ############################################################################### 54 | #*.doc diff=astextplain 55 | #*.DOC diff=astextplain 56 | #*.docx diff=astextplain 57 | #*.DOCX diff=astextplain 58 | #*.dot diff=astextplain 59 | #*.DOT diff=astextplain 60 | #*.pdf diff=astextplain 61 | #*.PDF diff=astextplain 62 | #*.rtf diff=astextplain 63 | #*.RTF diff=astextplain 64 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/RedisStorageFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Linq; 3 | using Hangfire.Logging; 4 | using Hangfire.Redis.StackExchange; 5 | using Hangfire.Redis.Tests.Utils; 6 | using Moq; 7 | using Xunit; 8 | 9 | namespace Hangfire.Redis.Tests 10 | { 11 | [Collection("Sequential")] 12 | public class RedisStorageFacts 13 | { 14 | [Fact, CleanRedis] 15 | public void GetStateHandlers_ReturnsAllHandlers() 16 | { 17 | var storage = CreateStorage(); 18 | 19 | var handlers = storage.GetStateHandlers(); 20 | 21 | var handlerTypes = handlers.Select(x => x.GetType()).ToArray(); 22 | Assert.Contains(typeof(FailedStateHandler), handlerTypes); 23 | Assert.Contains(typeof(ProcessingStateHandler), handlerTypes); 24 | Assert.Contains(typeof(SucceededStateHandler), handlerTypes); 25 | Assert.Contains(typeof(DeletedStateHandler), handlerTypes); 26 | } 27 | 28 | [Fact] 29 | public void DbFromConnectionStringIsUsed() 30 | { 31 | var storage = new RedisStorage(String.Format("{0},defaultDatabase=5", RedisUtils.GetHostAndPort())); 32 | Assert.Equal(5, storage.Db); 33 | } 34 | 35 | [Fact] 36 | public void PasswordFromToStringIsNotShown() 37 | { 38 | string password = Guid.NewGuid().ToString("N"); 39 | var storage = new RedisStorage(String.Format("{0},password={1}", RedisUtils.GetHostAndPort(), password)); 40 | Assert.DoesNotContain(password, storage.ToString()); 41 | } 42 | 43 | [Fact] 44 | public void PasswordFromWriteOptionsToLogIsNotShown() 45 | { 46 | string password = Guid.NewGuid().ToString("N"); 47 | var storage = new RedisStorage(String.Format("{0},password={1}", RedisUtils.GetHostAndPort(), password)); 48 | 49 | string loggedMessage = null; 50 | 51 | var logMock = new Mock(); 52 | 53 | logMock.Setup(p => p.Log(LogLevel.Debug, null, null)).Returns(true); // logger.IsDebugEnabled() 54 | logMock.Setup(p => p.Log( 55 | LogLevel.Debug, 56 | It.Is>(f => f != null && f.Invoke().StartsWith("ConnectionString: ")), 57 | null)) 58 | .Callback((LogLevel lvl, Func msg, Exception ex) => { loggedMessage = msg.Invoke(); }) 59 | .Returns(true) 60 | .Verifiable(); 61 | 62 | storage.WriteOptionsToLog(logMock.Object); 63 | 64 | logMock.Verify(); 65 | Assert.DoesNotContain(password, loggedMessage); 66 | } 67 | 68 | private static RedisStorage CreateStorage() 69 | { 70 | var options = new RedisStorageOptions() { Db = RedisUtils.GetDb() }; 71 | return new RedisStorage(RedisUtils.GetHostAndPort(), options); 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/RedisStorageExtensions.cs: -------------------------------------------------------------------------------- 1 | // Copyright � 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | using Hangfire.Annotations; 19 | using StackExchange.Redis; 20 | 21 | namespace Hangfire.Redis.StackExchange 22 | { 23 | public static class RedisStorageExtensions 24 | { 25 | public static IGlobalConfiguration UseRedisStorage( 26 | [NotNull] this IGlobalConfiguration configuration) 27 | { 28 | if (configuration == null) throw new ArgumentNullException(nameof(configuration)); 29 | var storage = new RedisStorage(); 30 | GlobalJobFilters.Filters.Add(new HangfireSubscriber()); 31 | return configuration.UseStorage(storage); 32 | } 33 | 34 | public static IGlobalConfiguration UseRedisStorage( 35 | [NotNull] this IGlobalConfiguration configuration, 36 | [NotNull] IConnectionMultiplexer connectionMultiplexer, 37 | RedisStorageOptions options = null) 38 | { 39 | if (configuration == null) throw new ArgumentNullException(nameof(configuration)); 40 | if (connectionMultiplexer == null) throw new ArgumentNullException(nameof(connectionMultiplexer)); 41 | var storage = new RedisStorage(connectionMultiplexer, options); 42 | GlobalJobFilters.Filters.Add(new HangfireSubscriber()); 43 | return configuration.UseStorage(storage); 44 | } 45 | 46 | 47 | public static IGlobalConfiguration UseRedisStorage( 48 | [NotNull] this IGlobalConfiguration configuration, 49 | [NotNull] string nameOrConnectionString, 50 | RedisStorageOptions options = null) 51 | { 52 | if (configuration == null) throw new ArgumentNullException(nameof(configuration)); 53 | if (nameOrConnectionString == null) throw new ArgumentNullException(nameof(nameOrConnectionString)); 54 | var storage = new RedisStorage(nameOrConnectionString, options); 55 | GlobalJobFilters.Filters.Add(new HangfireSubscriber()); 56 | return configuration.UseStorage(storage); 57 | } 58 | } 59 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/RedisStorageOptions.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | 19 | namespace Hangfire.Redis.StackExchange 20 | { 21 | public class RedisStorageOptions 22 | { 23 | public const string DefaultPrefix = "{hangfire}:"; 24 | 25 | public RedisStorageOptions() 26 | { 27 | InvisibilityTimeout = TimeSpan.FromMinutes(30); 28 | FetchTimeout = TimeSpan.FromMinutes(3); 29 | ExpiryCheckInterval = TimeSpan.FromHours(1); 30 | Db = 0; 31 | Prefix = DefaultPrefix; 32 | SucceededListSize = 499; 33 | DeletedListSize = 499; 34 | LifoQueues = new string[0]; 35 | UseTransactions = true; 36 | } 37 | 38 | /// 39 | /// It's a part of mechanism to requeue the job if the server processing it died for some reason 40 | /// 41 | /// 42 | public TimeSpan InvisibilityTimeout { get; set; } 43 | 44 | /// 45 | /// It's a fallback for fetching jobs if pub/sub mechanism fails. This software use redis pub/sub to be notified 46 | /// when a new job has been enqueued. Redis pub/sub however doesn't guarantee delivery so, should the 47 | /// RedisConnection lose a message, within the FetchTimeout the queue will be scanned from scratch 48 | /// and any waiting jobs will be processed. 49 | /// 50 | public TimeSpan FetchTimeout { get; set; } 51 | 52 | /// 53 | /// Time that should pass between expired jobs cleanup. 54 | /// RedisStorage uses non-expiring keys, so to clean up the store there is a thread running a IServerComponent 55 | /// that take care of deleting expired jobs from redis. 56 | /// 57 | public TimeSpan ExpiryCheckInterval { get; set; } 58 | public string Prefix { get; set; } 59 | public int Db { get; set; } 60 | public int SucceededListSize { get; set; } 61 | public int DeletedListSize { get; set; } 62 | public string[] LifoQueues { get; set; } 63 | public bool UseTransactions { get; set; } 64 | } 65 | } -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/ExpiredJobsWatcherFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using Hangfire.Common; 4 | using Hangfire.Redis.StackExchange; 5 | using Hangfire.Redis.Tests.Utils; 6 | using Hangfire.Server; 7 | using Xunit; 8 | 9 | namespace Hangfire.Redis.Tests 10 | { 11 | [CleanRedis, Collection("Sequential")] 12 | public class ExpiredJobsWatcherFacts 13 | { 14 | private static readonly TimeSpan CheckInterval = TimeSpan.FromSeconds(1); 15 | 16 | private readonly RedisStorage _storage; 17 | private readonly CancellationTokenSource _cts; 18 | 19 | public ExpiredJobsWatcherFacts() 20 | { 21 | var options = new RedisStorageOptions() {Db = RedisUtils.GetDb()}; 22 | _storage = new RedisStorage(RedisUtils.GetHostAndPort(), options); 23 | _cts = new CancellationTokenSource(); 24 | _cts.Cancel(); 25 | } 26 | 27 | [Fact] 28 | public void Ctor_ThrowsAnException_WhenStorageIsNull() 29 | { 30 | Assert.Throws("storage", 31 | () => new ExpiredJobsWatcher(null, CheckInterval)); 32 | } 33 | 34 | [Fact] 35 | public void Ctor_ThrowsAnException_WhenCheckIntervalIsZero() 36 | { 37 | Assert.Throws("checkInterval", 38 | () => new ExpiredJobsWatcher(_storage, TimeSpan.Zero)); 39 | } 40 | 41 | [Fact] 42 | public void Ctor_ThrowsAnException_WhenCheckIntervalIsNegative() 43 | { 44 | Assert.Throws("checkInterval", 45 | () => new ExpiredJobsWatcher(_storage, TimeSpan.FromSeconds(-1))); 46 | } 47 | 48 | [Fact, CleanRedis] 49 | public void Execute_DeletesNonExistingJobs() 50 | { 51 | var redis = RedisUtils.CreateClient(); 52 | 53 | Assert.Equal(0, redis.ListLength("{hangfire}:succeeded")); 54 | Assert.Equal(0, redis.ListLength("{hangfire}:deleted")); 55 | 56 | // Arrange 57 | redis.ListRightPush("{hangfire}:succeded", "my-job"); 58 | redis.ListRightPush("{hangfire}:deleted", "other-job"); 59 | 60 | var watcher = CreateWatcher(); 61 | 62 | // Act 63 | watcher.Execute(_cts.Token); 64 | 65 | // Assert 66 | Assert.Equal(0, redis.ListLength("{hangfire}:succeeded")); 67 | Assert.Equal(0, redis.ListLength("{hangfire}:deleted")); 68 | } 69 | 70 | [Fact, CleanRedis] 71 | public void Execute_DoesNotDeleteExistingJobs() 72 | { 73 | var redis = RedisUtils.CreateClient(); 74 | // Arrange 75 | redis.ListRightPush("{hangfire}:succeeded", "my-job"); 76 | redis.HashSet("{hangfire}:job:my-job", "Fetched", 77 | JobHelper.SerializeDateTime(DateTime.UtcNow.AddDays(-1))); 78 | 79 | redis.ListRightPush("{hangfire}:deleted", "other-job"); 80 | redis.HashSet("{hangfire}:job:other-job", "Fetched", 81 | JobHelper.SerializeDateTime(DateTime.UtcNow.AddDays(-1))); 82 | 83 | var watcher = CreateWatcher(); 84 | 85 | // Act 86 | watcher.Execute(_cts.Token); 87 | 88 | // Assert 89 | Assert.Equal(1, redis.ListLength("{hangfire}:succeeded")); 90 | Assert.Equal(1, redis.ListLength("{hangfire}:deleted")); 91 | } 92 | 93 | #pragma warning disable 618 94 | private IServerComponent CreateWatcher() 95 | #pragma warning restore 618 96 | { 97 | return new ExpiredJobsWatcher(_storage, CheckInterval); 98 | } 99 | } 100 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/ExpiredJobsWatcher.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Linq; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Hangfire.Logging; 7 | using Hangfire.Server; 8 | using StackExchange.Redis; 9 | 10 | namespace Hangfire.Redis.StackExchange 11 | { 12 | #pragma warning disable 618 13 | internal class ExpiredJobsWatcher : IServerComponent 14 | #pragma warning restore 618 15 | { 16 | private static readonly ILog Logger = LogProvider.For(); 17 | 18 | private readonly RedisStorage _storage; 19 | private readonly TimeSpan _checkInterval; 20 | 21 | private static readonly string[] ProcessedKeys = 22 | { 23 | "succeeded", 24 | "deleted" 25 | }; 26 | 27 | public ExpiredJobsWatcher(RedisStorage storage, TimeSpan checkInterval) 28 | { 29 | if (storage == null) 30 | throw new ArgumentNullException(nameof(storage)); 31 | if (checkInterval.Ticks <= 0) 32 | throw new ArgumentOutOfRangeException(nameof(checkInterval), "Check interval should be positive."); 33 | 34 | _storage = storage; 35 | _checkInterval = checkInterval; 36 | } 37 | 38 | public override string ToString() 39 | { 40 | return GetType().ToString(); 41 | } 42 | 43 | void IServerComponent.Execute(CancellationToken cancellationToken) 44 | { 45 | using (var connection = (RedisConnection) _storage.GetConnection()) 46 | { 47 | var redis = connection.Redis; 48 | 49 | foreach (var key in ProcessedKeys) 50 | { 51 | var redisKey = _storage.GetRedisKey(key); 52 | 53 | var count = redis.ListLength(redisKey); 54 | if (count == 0) continue; 55 | 56 | Logger.InfoFormat("Removing expired records from the '{0}' list...", key); 57 | 58 | const int batchSize = 100; 59 | var keysToRemove = new List(); 60 | 61 | for (var last = count - 1; last >= 0; last -= batchSize) 62 | { 63 | var first = Math.Max(0, last - batchSize + 1); 64 | 65 | var jobIds = redis.ListRange(redisKey, first, last).ToStringArray(); 66 | if (jobIds.Length == 0) continue; 67 | 68 | var pipeline = redis.CreateBatch(); 69 | var tasks = new Task[jobIds.Length]; 70 | 71 | for (var i = 0; i < jobIds.Length; i++) 72 | { 73 | tasks[i] = pipeline.KeyExistsAsync(_storage.GetRedisKey($"job:{jobIds[i]}")); 74 | } 75 | 76 | pipeline.Execute(); 77 | Task.WaitAll(tasks); 78 | 79 | keysToRemove.AddRange(jobIds.Where((t, i) => !((Task) tasks[i]).Result)); 80 | } 81 | 82 | if (keysToRemove.Count == 0) continue; 83 | 84 | Logger.InfoFormat("Removing {0} expired jobs from '{1}' list...", keysToRemove.Count, key); 85 | 86 | using (var transaction = connection.CreateWriteTransaction()) 87 | { 88 | foreach (var jobId in keysToRemove) 89 | { 90 | transaction.RemoveFromList(key, jobId); 91 | } 92 | 93 | transaction.Commit(); 94 | } 95 | } 96 | } 97 | 98 | cancellationToken.WaitHandle.WaitOne(_checkInterval); 99 | } 100 | } 101 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/RedisFetchedJob.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | using Hangfire.Annotations; 19 | using Hangfire.Common; 20 | using Hangfire.Storage; 21 | using StackExchange.Redis; 22 | 23 | namespace Hangfire.Redis.StackExchange 24 | { 25 | internal class RedisFetchedJob : IFetchedJob 26 | { 27 | private readonly RedisStorage _storage; 28 | private readonly IDatabase _redis; 29 | private bool _disposed; 30 | private bool _removedFromQueue; 31 | private bool _requeued; 32 | 33 | public RedisFetchedJob( 34 | [NotNull] RedisStorage storage, 35 | [NotNull] IDatabase redis, 36 | [NotNull] string jobId, 37 | [NotNull] string queue, 38 | [CanBeNull] DateTime? fetchedAt) 39 | { 40 | _storage = storage ?? throw new ArgumentNullException(nameof(storage)); 41 | _redis = redis ?? throw new ArgumentNullException(nameof(redis)); 42 | JobId = jobId ?? throw new ArgumentNullException(nameof(jobId)); 43 | Queue = queue ?? throw new ArgumentNullException(nameof(queue)); 44 | FetchedAt = fetchedAt; 45 | } 46 | 47 | public string JobId { get; } 48 | public string Queue { get; } 49 | public DateTime? FetchedAt { get; } 50 | 51 | private DateTime? GetFetchedValue() 52 | { 53 | return JobHelper.DeserializeNullableDateTime(_redis.HashGet(_storage.GetRedisKey($"job:{JobId}"), "Fetched")); 54 | } 55 | 56 | public void RemoveFromQueue() 57 | { 58 | var fetchedAt = GetFetchedValue(); 59 | if (_storage.UseTransactions) 60 | { 61 | var transaction = _redis.CreateTransaction(); 62 | 63 | if (fetchedAt == FetchedAt) 64 | { 65 | RemoveFromFetchedListAsync(transaction); 66 | } 67 | transaction.PublishAsync(_storage.SubscriptionChannel, JobId); 68 | transaction.Execute(); 69 | } 70 | else 71 | { 72 | if (fetchedAt == FetchedAt) 73 | { 74 | RemoveFromFetchedList(_redis); 75 | } 76 | 77 | _redis.Publish(_storage.SubscriptionChannel, JobId); 78 | } 79 | _removedFromQueue = true; 80 | } 81 | 82 | public void Requeue() 83 | { 84 | var fetchedAt = GetFetchedValue(); 85 | if (_storage.UseTransactions) 86 | { 87 | var transaction = _redis.CreateTransaction(); 88 | transaction.ListRightPushAsync(_storage.GetRedisKey($"queue:{Queue}"), JobId); 89 | if (fetchedAt == FetchedAt) 90 | { 91 | RemoveFromFetchedListAsync(transaction); 92 | } 93 | 94 | transaction.PublishAsync(_storage.SubscriptionChannel, JobId); 95 | transaction.Execute(); 96 | } else 97 | { 98 | _redis.ListRightPush(_storage.GetRedisKey($"queue:{Queue}"), JobId); 99 | if (fetchedAt == FetchedAt) 100 | { 101 | RemoveFromFetchedList(_redis); 102 | } 103 | 104 | _redis.Publish(_storage.SubscriptionChannel, JobId); 105 | } 106 | _requeued = true; 107 | } 108 | 109 | public void Dispose() 110 | { 111 | if (_disposed) return; 112 | 113 | if (!_removedFromQueue && !_requeued) 114 | { 115 | Requeue(); 116 | } 117 | 118 | _disposed = true; 119 | } 120 | 121 | private void RemoveFromFetchedListAsync(IDatabaseAsync databaseAsync) 122 | { 123 | databaseAsync.ListRemoveAsync(_storage.GetRedisKey($"queue:{Queue}:dequeued"), JobId, -1); 124 | databaseAsync.HashDeleteAsync(_storage.GetRedisKey($"job:{JobId}"), ["Fetched", "Checked"]); 125 | } 126 | private void RemoveFromFetchedList(IDatabase database) 127 | { 128 | database.ListRemove(_storage.GetRedisKey($"queue:{Queue}:dequeued"), JobId, -1); 129 | database.HashDelete(_storage.GetRedisKey($"job:{JobId}"), ["Fetched", "Checked"]); 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/RedisLockFacts.cs: -------------------------------------------------------------------------------- 1 | using Hangfire.Storage; 2 | using StackExchange.Redis; 3 | using System; 4 | using System.Threading; 5 | using System.Threading.Tasks; 6 | using Hangfire.Redis.StackExchange; 7 | using Hangfire.Redis.Tests.Utils; 8 | using Xunit; 9 | 10 | namespace Hangfire.Redis.Tests 11 | { 12 | [Collection("Sequential")] 13 | public class RedisLockFacts 14 | { 15 | [Fact, CleanRedis] 16 | public void AcquireInSequence() 17 | { 18 | var db = RedisUtils.CreateClient(); 19 | 20 | using (var testLock = RedisLock.Acquire(db, "testLock", TimeSpan.FromMilliseconds(1))) 21 | Assert.NotNull(testLock); 22 | using (var testLock = RedisLock.Acquire(db, "testLock", TimeSpan.FromMilliseconds(1))) 23 | Assert.NotNull(testLock); 24 | } 25 | 26 | [Fact, CleanRedis] 27 | public void AcquireNested() 28 | { 29 | var db = RedisUtils.CreateClient(); 30 | 31 | using (var testLock1 = RedisLock.Acquire(db, "testLock", TimeSpan.FromMilliseconds(100))) 32 | { 33 | Assert.NotNull(testLock1); 34 | 35 | using (var testLock2 = RedisLock.Acquire(db, "testLock", TimeSpan.FromMilliseconds(100))) 36 | { 37 | Assert.NotNull(testLock2); 38 | } 39 | } 40 | } 41 | 42 | [Fact, CleanRedis] 43 | public void AcquireFromMultipleThreads() 44 | { 45 | var db = RedisUtils.CreateClient(); 46 | 47 | var sync = new ManualResetEventSlim(); 48 | 49 | var thread1 = new Thread(state => 50 | { 51 | using (var testLock1 = RedisLock.Acquire(db, "test", TimeSpan.FromMilliseconds(50))) 52 | { 53 | // ensure nested lock release doesn't release parent lock 54 | using (var testLock2 = RedisLock.Acquire(db, "test", TimeSpan.FromMilliseconds(50))) 55 | { 56 | } 57 | 58 | sync.Set(); 59 | Thread.Sleep(200); 60 | } 61 | }); 62 | 63 | var thread2 = new Thread(state => 64 | { 65 | Assert.True(sync.Wait(1000)); 66 | 67 | Assert.Throws(() => 68 | { 69 | using (var testLock2 = RedisLock.Acquire(db, "test", TimeSpan.FromMilliseconds(50))) 70 | { 71 | } 72 | }); 73 | }); 74 | 75 | thread1.Start(); 76 | thread2.Start(); 77 | thread1.Join(); 78 | thread2.Join(); 79 | } 80 | 81 | //private async Task NestedTask(IDatabase db) 82 | //{ 83 | 84 | // await Task.Yield(); 85 | // using var lestLock2 = RedisLock.Acquire(db, "test", TimeSpan.FromMilliseconds(10)); 86 | // Assert.NotNull(lestLock2); 87 | //} 88 | 89 | //Test below will fail due to the changed storage model of HeldLocks in RedisLock (it should be AsyncLocal instead of ThreadLocal) 90 | //It seems that something is wrong when run in hangfire but I don't have a repro. 91 | //Giving that Hangfire as of 1.8.7 still doesn't use Tasks (milestone set for 2.0) I'm leaving HeldLocks in a ThreadLocal 92 | //and commenting out the failing test 93 | 94 | //[Fact, CleanRedis] 95 | //public async Task AcquireFromNestedTask() 96 | //{ 97 | // var db = RedisUtils.CreateClient(); 98 | 99 | // using var lock1 = RedisLock.Acquire(db, "test", TimeSpan.FromMilliseconds(50)); 100 | // Assert.NotNull(lock1); 101 | 102 | // await Task.Delay(100); 103 | 104 | // await Task.Run(() => NestedTask(db)); 105 | 106 | //} 107 | 108 | [Fact, CleanRedis] 109 | public void SlidingExpirationTest() 110 | { 111 | var db = RedisUtils.CreateClient(); 112 | 113 | var sync1 = new ManualResetEventSlim(); 114 | var sync2 = new ManualResetEventSlim(); 115 | 116 | var thread1 = new Thread(state => 117 | { 118 | using (var testLock1 = RedisLock.Acquire(db, "testLock", TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(110))) 119 | { 120 | Assert.NotNull(testLock1); 121 | 122 | // sleep a bit more than holdDuration 123 | Thread.Sleep(250); 124 | sync1.Set(); 125 | sync2.Wait(); 126 | } 127 | }); 128 | 129 | var thread2 = new Thread(state => 130 | { 131 | Assert.True(sync1.Wait(1000)); 132 | 133 | Assert.Throws(() => 134 | { 135 | using (var testLock2 = RedisLock.Acquire(db, "testLock", TimeSpan.FromMilliseconds(100))) 136 | { 137 | } 138 | }); 139 | }); 140 | 141 | thread1.Start(); 142 | thread2.Start(); 143 | thread2.Join(); 144 | sync2.Set(); 145 | thread1.Join(); 146 | } 147 | } 148 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/RedisInfoKeys.cs: -------------------------------------------------------------------------------- 1 | namespace Hangfire.Redis.StackExchange 2 | { 3 | public class RedisInfoKeys 4 | { 5 | public static string redis_version = "redis_version"; 6 | public static string redis_git_sha1 = "redis_git_sha1"; 7 | public static string redis_git_dirty = "redis_git_dirty"; 8 | public static string redis_build_id = "redis_build_id"; 9 | public static string redis_mode = "redis_mode"; 10 | public static string os = "os"; 11 | public static string arch_bits = "arch_bits"; 12 | public static string multiplexing_api = "multiplexing_api"; 13 | public static string process_id = "process_id"; 14 | public static string run_id = "run_id"; 15 | public static string tcp_port = "tcp_port"; 16 | public static string uptime_in_seconds = "uptime_in_seconds"; 17 | public static string uptime_in_days = "uptime_in_days"; 18 | public static string hz = "hz"; 19 | public static string lru_clock = "lru_clock"; 20 | public static string config_file = "config_file"; 21 | public static string connected_clients = "connected_clients"; 22 | public static string client_longest_output_list = "client_longest_output_list"; 23 | public static string client_biggest_input_buf = "client_biggest_input_buf"; 24 | public static string blocked_clients = "blocked_clients"; 25 | public static string used_memory = "used_memory"; 26 | public static string used_memory_human = "used_memory_human"; 27 | public static string used_memory_rss = "used_memory_rss"; 28 | public static string used_memory_peak = "used_memory_peak"; 29 | public static string used_memory_peak_human = "used_memory_peak_human"; 30 | public static string used_memory_lua = "used_memory_lua"; 31 | public static string mem_fragmentation_ratio = "mem_fragmentation_ratio"; 32 | public static string mem_allocator = "mem_allocator"; 33 | public static string loading = "loading"; 34 | public static string rdb_changes_since_last_save = "rdb_changes_since_last_save"; 35 | public static string rdb_bgsave_in_progress = "rdb_bgsave_in_progress"; 36 | public static string rdb_last_save_time = "rdb_last_save_time"; 37 | public static string rdb_last_bgsave_status = "rdb_last_bgsave_status"; 38 | public static string rdb_last_bgsave_time_sec = "rdb_last_bgsave_time_sec"; 39 | public static string rdb_current_bgsave_time_sec = "rdb_current_bgsave_time_sec"; 40 | public static string aof_enabled = "aof_enabled"; 41 | public static string aof_rewrite_in_progress = "aof_rewrite_in_progress"; 42 | public static string aof_rewrite_scheduled = "aof_rewrite_scheduled"; 43 | public static string aof_last_rewrite_time_sec = "aof_last_rewrite_time_sec"; 44 | public static string aof_current_rewrite_time_sec = "aof_current_rewrite_time_sec"; 45 | public static string aof_last_bgrewrite_status = "aof_last_bgrewrite_status"; 46 | public static string aof_last_write_status = "aof_last_write_status"; 47 | public static string total_connections_received = "total_connections_received"; 48 | public static string total_commands_processed = "total_commands_processed"; 49 | public static string instantaneous_ops_per_sec = "instantaneous_ops_per_sec"; 50 | public static string total_net_input_bytes = "total_net_input_bytes"; 51 | public static string total_net_output_bytes = "total_net_output_bytes"; 52 | public static string instantaneous_input_kbps = "instantaneous_input_kbps"; 53 | public static string instantaneous_output_kbps = "instantaneous_output_kbps"; 54 | public static string rejected_connections = "rejected_connections"; 55 | public static string sync_full = "sync_full"; 56 | public static string sync_partial_ok = "sync_partial_ok"; 57 | public static string sync_partial_err = "sync_partial_err"; 58 | public static string expired_keys = "expired_keys"; 59 | public static string evicted_keys = "evicted_keys"; 60 | public static string keyspace_hits = "keyspace_hits"; 61 | public static string keyspace_misses = "keyspace_misses"; 62 | public static string pubsub_channels = "pubsub_channels"; 63 | public static string pubsub_patterns = "pubsub_patterns"; 64 | public static string latest_fork_usec = "latest_fork_usec"; 65 | public static string migrate_cached_sockets = "migrate_cached_sockets"; 66 | public static string role = "role"; 67 | public static string connected_slaves = "connected_slaves"; 68 | public static string master_repl_offset = "master_repl_offset"; 69 | public static string repl_backlog_active = "repl_backlog_active"; 70 | public static string repl_backlog_size = "repl_backlog_size"; 71 | public static string repl_backlog_first_byte_offset = "repl_backlog_first_byte_offset"; 72 | public static string repl_backlog_histlen = "repl_backlog_histlen"; 73 | public static string used_cpu_sys = "used_cpu_sys"; 74 | public static string used_cpu_user = "used_cpu_user"; 75 | public static string used_cpu_sys_children = "used_cpu_sys_children"; 76 | public static string used_cpu_user_children = "used_cpu_user_children"; 77 | public static string cluster_enabled = "cluster_enabled"; 78 | public static string db0 = "db0"; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/FetchedJobsWatcherFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading; 3 | using Hangfire.Common; 4 | using Hangfire.Redis.StackExchange; 5 | using Hangfire.Redis.Tests.Utils; 6 | using Xunit; 7 | 8 | namespace Hangfire.Redis.Tests 9 | { 10 | [CleanRedis, Collection("Sequential")] 11 | public class FetchedJobsWatcherFacts 12 | { 13 | private static readonly TimeSpan InvisibilityTimeout = TimeSpan.FromSeconds(10); 14 | 15 | private readonly RedisStorage _storage; 16 | private readonly CancellationTokenSource _cts; 17 | 18 | public FetchedJobsWatcherFacts() 19 | { 20 | var options = new RedisStorageOptions() {Db = RedisUtils.GetDb()}; 21 | _storage = new RedisStorage(RedisUtils.GetHostAndPort(), options); 22 | _cts = new CancellationTokenSource(); 23 | _cts.Cancel(); 24 | } 25 | 26 | [Fact] 27 | public void Ctor_ThrowsAnException_WhenStorageIsNull() 28 | { 29 | Assert.Throws("storage", 30 | () => new FetchedJobsWatcher(null, InvisibilityTimeout)); 31 | } 32 | 33 | [Fact] 34 | public void Ctor_ThrowsAnException_WhenInvisibilityTimeoutIsZero() 35 | { 36 | Assert.Throws("invisibilityTimeout", 37 | () => new FetchedJobsWatcher(_storage, TimeSpan.Zero)); 38 | } 39 | 40 | [Fact] 41 | public void Ctor_ThrowsAnException_WhenInvisibilityTimeoutIsNegative() 42 | { 43 | Assert.Throws("invisibilityTimeout", 44 | () => new FetchedJobsWatcher(_storage, TimeSpan.FromSeconds(-1))); 45 | } 46 | 47 | [Fact] 48 | public void Execute_EnqueuesTimedOutJobs_AndDeletesThemFromFetchedList() 49 | { 50 | var redis = RedisUtils.CreateClient(); 51 | // Arrange 52 | redis.SetAdd("{hangfire}:queues", "my-queue"); 53 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "my-job"); 54 | redis.HashSet("{hangfire}:job:my-job", "Fetched", 55 | JobHelper.SerializeDateTime(DateTime.UtcNow.AddDays(-1))); 56 | 57 | var watcher = CreateWatcher(); 58 | 59 | // Act 60 | watcher.Execute(_cts.Token); 61 | 62 | // Assert 63 | Assert.Equal(0, redis.ListLength("{hangfire}:queue:my-queue:dequeued")); 64 | 65 | var listEntry = (string) redis.ListRightPop("{hangfire}:queue:my-queue"); 66 | Assert.Equal("my-job", listEntry); 67 | 68 | var job = redis.HashGetAll("{hangfire}:job:my-job"); 69 | Assert.DoesNotContain(job, x => x.Name == "Fetched"); 70 | } 71 | 72 | [Fact, CleanRedis] 73 | public void Execute_MarksDequeuedJobAsChecked_IfItHasNoFetchedFlagSet() 74 | { 75 | var redis = RedisUtils.CreateClient(); 76 | // Arrange 77 | redis.SetAdd("{hangfire}:queues", "my-queue"); 78 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "my-job"); 79 | 80 | var watcher = CreateWatcher(); 81 | 82 | // Act 83 | watcher.Execute(_cts.Token); 84 | 85 | Assert.NotNull(JobHelper.DeserializeNullableDateTime( 86 | redis.HashGet("{hangfire}:job:my-job", "Checked"))); 87 | } 88 | 89 | [Fact, CleanRedis] 90 | public void Execute_EnqueuesCheckedAndTimedOutJob_IfNoFetchedFlagSet() 91 | { 92 | var redis = RedisUtils.CreateClient(); 93 | // Arrange 94 | redis.SetAdd("{hangfire}:queues", "my-queue"); 95 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "my-job"); 96 | redis.HashSet("{hangfire}:job:my-job", "Checked", 97 | JobHelper.SerializeDateTime(DateTime.UtcNow.AddDays(-1))); 98 | 99 | var watcher = CreateWatcher(); 100 | 101 | // Act 102 | watcher.Execute(_cts.Token); 103 | 104 | // Arrange 105 | Assert.Equal(0, redis.ListLength("{hangfire}:queue:my-queue:dequeued")); 106 | Assert.Equal(1, redis.ListLength("{hangfire}:queue:my-queue")); 107 | 108 | var job = redis.HashGetAll("{hangfire}:job:my-job"); 109 | Assert.DoesNotContain(job, x => x.Name == "Checked"); 110 | } 111 | 112 | [Fact, CleanRedis] 113 | public void Execute_DoesNotEnqueueTimedOutByCheckedFlagJob_IfFetchedFlagSet() 114 | { 115 | var redis = RedisUtils.CreateClient(); 116 | 117 | // Arrange 118 | redis.SetAdd("{hangfire}:queues", "my-queue"); 119 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "my-job"); 120 | redis.HashSet("{hangfire}:job:my-job", "Checked", 121 | JobHelper.SerializeDateTime(DateTime.UtcNow.AddDays(-1))); 122 | redis.HashSet("{hangfire}:job:my-job", "Fetched", 123 | JobHelper.SerializeDateTime(DateTime.UtcNow)); 124 | 125 | var watcher = CreateWatcher(); 126 | 127 | // Act 128 | watcher.Execute(_cts.Token); 129 | 130 | // Assert 131 | Assert.Equal(1, redis.ListLength("{hangfire}:queue:my-queue:dequeued")); 132 | } 133 | 134 | private FetchedJobsWatcher CreateWatcher() 135 | { 136 | return new FetchedJobsWatcher(_storage, InvisibilityTimeout); 137 | } 138 | } 139 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | ## Ignore Visual Studio temporary files, build results, and 2 | ## files generated by popular Visual Studio add-ons. 3 | 4 | # User-specific files 5 | *.suo 6 | *.user 7 | *.userosscache 8 | *.sln.docstates 9 | 10 | # User-specific files (MonoDevelop/Xamarin Studio) 11 | *.userprefs 12 | 13 | # Build results 14 | [Dd]ebug/ 15 | [Dd]ebugPublic/ 16 | [Rr]elease/ 17 | [Rr]eleases/ 18 | x64/ 19 | x86/ 20 | bld/ 21 | [Bb]in/ 22 | [Oo]bj/ 23 | [Ll]og/ 24 | 25 | # Visual Studio 2015 cache/options directory 26 | .vs/ 27 | # Uncomment if you have tasks that create the project's static files in wwwroot 28 | #wwwroot/ 29 | 30 | # MSTest test Results 31 | [Tt]est[Rr]esult*/ 32 | [Bb]uild[Ll]og.* 33 | 34 | # NUNIT 35 | *.VisualState.xml 36 | TestResult.xml 37 | 38 | # Build Results of an ATL Project 39 | [Dd]ebugPS/ 40 | [Rr]eleasePS/ 41 | dlldata.c 42 | 43 | # DNX 44 | project.lock.json 45 | project.fragment.lock.json 46 | artifacts/ 47 | 48 | *_i.c 49 | *_p.c 50 | *_i.h 51 | *.ilk 52 | *.meta 53 | *.obj 54 | *.pch 55 | *.pdb 56 | *.pgc 57 | *.pgd 58 | *.rsp 59 | *.sbr 60 | *.tlb 61 | *.tli 62 | *.tlh 63 | *.tmp 64 | *.tmp_proj 65 | *.log 66 | *.vspscc 67 | *.vssscc 68 | .builds 69 | *.pidb 70 | *.svclog 71 | *.scc 72 | 73 | # Chutzpah Test files 74 | _Chutzpah* 75 | 76 | # Visual C++ cache files 77 | ipch/ 78 | *.aps 79 | *.ncb 80 | *.opendb 81 | *.opensdf 82 | *.sdf 83 | *.cachefile 84 | *.VC.db 85 | *.VC.VC.opendb 86 | 87 | # Visual Studio profiler 88 | *.psess 89 | *.vsp 90 | *.vspx 91 | *.sap 92 | 93 | # TFS 2012 Local Workspace 94 | $tf/ 95 | 96 | # Guidance Automation Toolkit 97 | *.gpState 98 | 99 | # ReSharper is a .NET coding add-in 100 | _ReSharper*/ 101 | *.[Rr]e[Ss]harper 102 | *.DotSettings.user 103 | 104 | # JustCode is a .NET coding add-in 105 | .JustCode 106 | 107 | # TeamCity is a build add-in 108 | _TeamCity* 109 | 110 | # DotCover is a Code Coverage Tool 111 | *.dotCover 112 | 113 | # NCrunch 114 | _NCrunch_* 115 | .*crunch*.local.xml 116 | nCrunchTemp_* 117 | 118 | # MightyMoose 119 | *.mm.* 120 | AutoTest.Net/ 121 | 122 | # Web workbench (sass) 123 | .sass-cache/ 124 | 125 | # Installshield output folder 126 | [Ee]xpress/ 127 | 128 | # DocProject is a documentation generator add-in 129 | DocProject/buildhelp/ 130 | DocProject/Help/*.HxT 131 | DocProject/Help/*.HxC 132 | DocProject/Help/*.hhc 133 | DocProject/Help/*.hhk 134 | DocProject/Help/*.hhp 135 | DocProject/Help/Html2 136 | DocProject/Help/html 137 | 138 | # Click-Once directory 139 | publish/ 140 | 141 | # Publish Web Output 142 | *.[Pp]ublish.xml 143 | *.azurePubxml 144 | # TODO: Comment the next line if you want to checkin your web deploy settings 145 | # but database connection strings (with potential passwords) will be unencrypted 146 | #*.pubxml 147 | *.publishproj 148 | 149 | # Microsoft Azure Web App publish settings. Comment the next line if you want to 150 | # checkin your Azure Web App publish settings, but sensitive information contained 151 | # in these scripts will be unencrypted 152 | PublishScripts/ 153 | 154 | # NuGet Packages 155 | *.nupkg 156 | # The packages folder can be ignored because of Package Restore 157 | **/packages/* 158 | # except build/, which is used as an MSBuild target. 159 | !**/packages/build/ 160 | # Uncomment if necessary however generally it will be regenerated when needed 161 | #!**/packages/repositories.config 162 | # NuGet v3's project.json files produces more ignoreable files 163 | *.nuget.props 164 | *.nuget.targets 165 | 166 | # Microsoft Azure Build Output 167 | csx/ 168 | *.build.csdef 169 | 170 | # Microsoft Azure Emulator 171 | ecf/ 172 | rcf/ 173 | 174 | # Windows Store app package directories and files 175 | AppPackages/ 176 | BundleArtifacts/ 177 | Package.StoreAssociation.xml 178 | _pkginfo.txt 179 | 180 | # Visual Studio cache files 181 | # files ending in .cache can be ignored 182 | *.[Cc]ache 183 | # but keep track of directories ending in .cache 184 | !*.[Cc]ache/ 185 | 186 | # Others 187 | ClientBin/ 188 | ~$* 189 | *~ 190 | *.dbmdl 191 | *.dbproj.schemaview 192 | *.jfm 193 | *.pfx 194 | *.publishsettings 195 | node_modules/ 196 | orleans.codegen.cs 197 | 198 | # Since there are multiple workflows, uncomment next line to ignore bower_components 199 | # (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) 200 | #bower_components/ 201 | 202 | # RIA/Silverlight projects 203 | Generated_Code/ 204 | 205 | # Backup & report files from converting an old project file 206 | # to a newer Visual Studio version. Backup files are not needed, 207 | # because we have git ;-) 208 | _UpgradeReport_Files/ 209 | Backup*/ 210 | UpgradeLog*.XML 211 | UpgradeLog*.htm 212 | 213 | # SQL Server files 214 | *.mdf 215 | *.ldf 216 | 217 | # Business Intelligence projects 218 | *.rdl.data 219 | *.bim.layout 220 | *.bim_*.settings 221 | 222 | # Microsoft Fakes 223 | FakesAssemblies/ 224 | 225 | # GhostDoc plugin setting file 226 | *.GhostDoc.xml 227 | 228 | # Node.js Tools for Visual Studio 229 | .ntvs_analysis.dat 230 | 231 | # Visual Studio 6 build log 232 | *.plg 233 | 234 | # Visual Studio 6 workspace options file 235 | *.opt 236 | 237 | # Visual Studio LightSwitch build output 238 | **/*.HTMLClient/GeneratedArtifacts 239 | **/*.DesktopClient/GeneratedArtifacts 240 | **/*.DesktopClient/ModelManifest.xml 241 | **/*.Server/GeneratedArtifacts 242 | **/*.Server/ModelManifest.xml 243 | _Pvt_Extensions 244 | 245 | # Paket dependency manager 246 | .paket/paket.exe 247 | paket-files/ 248 | 249 | # FAKE - F# Make 250 | .fake/ 251 | 252 | # JetBrains Rider 253 | .idea/ 254 | *.sln.iml 255 | 256 | # CodeRush 257 | .cr/ 258 | 259 | # Python Tools for Visual Studio (PTVS) 260 | __pycache__/ 261 | *.pyc 262 | /dump.rdb 263 | /dump.rdb 264 | -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/Hangfire.Redis.StackExchange.csproj: -------------------------------------------------------------------------------- 1 | 2 | 3 | netstandard2.0 4 | 12 5 | Hangfire.Redis.StackExchange 6 | true 7 | 1.12.0 8 | 1.12.0 9 | Marco Casamento and contributors 10 | Hangfire Redis Storage 11 | https://github.com/marcoCasamento/Hangfire.Redis.StackExchange 12 | 13 | Hangfire Redis Storage Based on Redis.StackExchange 14 | See http://hangfire.io/ for some more info on the main project and https://github.com/marcoCasamento/Hangfire.Redis.StackExchange for info on this 15 | 16 | README.md 17 | 18 | Enjoy Redis outstanding performance when fetching your jobs! 19 | Hangfire Redis storage use Redis to persists Job information, through Redis.StackExchange library. 20 | It also supports Batches (Pro Feature) 21 | 22 | 23 | 1.12.0 Fix #153 and all the chaneg 24 | - Resume all changes between 1after 1.9.3 4 , realigning the codegit 25 | - Bump Hangfire to 1.8.12 and StackExchange.Redis to 2.7.33 26 | 1.11.0 Fix #157 AND bring back the code from 1.9.4 27 | With this release I'm trying to start back from 1.9.4 (Latest release "approved for production" as 1.9.5 got unlisted) and I'm willing to review and recommit the code from there. One commit after the other. 28 | If anyone can help with testing, please drop a line to marco.casamento@gmail.com 29 | 1.10.1-Beta2 30 | - Fix #153 Appeared after 1.10.0 release. Only updates to this version for testing purposes and PLEASE report any issues. Stick to 1.9.3 for production. 31 | 1.10.0 32 | - Fix #135 -CHANGED BEHAVIOR- Library now requeue jobs from dead server (read comments in the issue) thanks to @Lexy2 33 | - Implement #150 (Replace async Redis calls with sync ones) thanks to @Lexy2 34 | - Fix #148 (Can't schedule a job without specifying the queue ) thanks to @Lexy2 35 | - Fix #142 (Scheduled jobs are returned incorrectly by the monitoring API) thanks to @Lexy2 36 | 1.9.5 ---UNLISTED---- 37 | - Address #139 support for netstandard2.0 38 | - Fix #145 Added Job Queue parameter to the job information 39 | - Update to Hangfire 1.8.12 and StackExchange.Redis 2.7.33 40 | Big thanks to @Lexy2 for all the work on this release 41 | 1.9.4 42 | - Update to Hangfire 1.8.7 43 | - Add Support for all features defined for the storage in Hangfire 1.8.7 44 | - Update StackExchange.Redis to 2.7.10 45 | 1.9.3 46 | - Fix the missing key prefixing ({hangfire}:) for GetSetCount and GetSetContains (thanks to BobSilent) 47 | 1.9.2 48 | - Failed jobs page lists new items first (thanks to toddlucas) 49 | 1.9.1 50 | - Downgrade to netStandard 2.0 for broader compatibility 51 | 1.9 52 | - Switched AsyncLocal storage of redis lock to ThreadLocal 53 | - BREAKING CHANGE: Drop support for net461 54 | - Added compatibility to Hangfire.Core 1.8 55 | - BREAKING CHANGE: Namespace changes to uniform with folders (It wrongly appeared in previous ver) 56 | 1.8.5 57 | - Speed up Recurring jobs Fetching (thanks to developingjim) 58 | 1.8.4 59 | - Fix #90, #91 concurrent access on dictinoary (thanks to luukholleman) 60 | - Fix #94 occasional error in monitoringApi (thanks to tsu1980) 61 | 1.8.3 62 | - Removed dependency to NewtonSoft.JSON (thanks to neyromant) 63 | 1.8.2 64 | - Updated Hangfire to 1.7.11 (thanks to abarducci) 65 | - Updated StackExchange.Redis to 2.1.30 (thanks to abarducci) 66 | 1.8.1 67 | - Updated Hangfire to 1.7.8 68 | - Fixed #82 (thanks to @bcuff) 69 | 1.8.0 70 | - Updated StackExchange.Redis to 2.0 (thanks to @andrebires) 71 | 1.7.2 72 | - Added support for Lifo Queues (thanks to AndreSteenbergen) 73 | - Added option to not use transaction (thanks to AndreSteenbergen) 74 | - Enabled sliding expiration for distributed locks (thanks to pieceofsummer) 75 | - Add epired jobs watch to cleanup succeeded/deleted lists (thanks to pieceofsummer) 76 | - Make succeeded and deleted lists size configurable (thanks to pieceofsummer) 77 | - Fix job state order (thanks to pieceofsummer) 78 | - Exclude Fetched property from job parameters (thanks to pieceofsummer) 79 | 1.7.1 80 | - Add expired jobs watcher to cleanup succeeded/deleted lists thanks to pieceofsummer 81 | 1.7.0 82 | - Redis Cluster support (#42 thanks to gzpbx) 83 | - Update to VS2017 (#48 thanks to ryanelian) 84 | 85 | Hangfire Redis 86 | https://github.com/marcoCasamento/Hangfire.Redis.StackExchange 87 | git 88 | True 89 | false 90 | Hangfire_Redis_StackExchange.snk 91 | LGPL-3.0-or-later 92 | false 93 | disable 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | all 104 | runtime; build; native; contentfiles; analyzers; buildtransitive 105 | 106 | 107 | 108 | 109 | 110 | 111 | -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/RedisLock.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | using System.Collections.Concurrent; 19 | using System.Diagnostics; 20 | using System.Threading; 21 | using Hangfire.Annotations; 22 | using Hangfire.Server; 23 | using Hangfire.States; 24 | using Hangfire.Storage; 25 | using StackExchange.Redis; 26 | 27 | namespace Hangfire.Redis.StackExchange 28 | { 29 | internal class RedisLock : IDisposable 30 | { 31 | private static readonly TimeSpan DefaultHoldDuration = TimeSpan.FromSeconds(30); 32 | private static readonly string OwnerId = Guid.NewGuid().ToString(); 33 | 34 | 35 | private static ThreadLocal> _heldLocks = new ThreadLocal>(); 36 | 37 | class StateBag 38 | { 39 | public PerformingContext PerformingContext { get; set; } 40 | public TimeSpan TimeSpan { get; set; } 41 | } 42 | 43 | private static ConcurrentDictionary HeldLocks 44 | { 45 | get 46 | { 47 | var value = _heldLocks.Value; 48 | if (value == null) 49 | _heldLocks.Value = value = new ConcurrentDictionary(); 50 | return value; 51 | } 52 | } 53 | 54 | private readonly IDatabase _redis; 55 | private readonly RedisKey _key; 56 | private readonly bool _holdsLock; 57 | private volatile bool _isDisposed = false; 58 | private readonly Timer _slidingExpirationTimer; 59 | 60 | private RedisLock([NotNull] IDatabase redis, RedisKey key, bool holdsLock, TimeSpan holdDuration) 61 | { 62 | _redis = redis; 63 | _key = key; 64 | _holdsLock = holdsLock; 65 | 66 | if (holdsLock) 67 | { 68 | HeldLocks.TryAdd(_key, 1); 69 | 70 | // start sliding expiration timer at half timeout intervals 71 | var halfLockHoldDuration = TimeSpan.FromTicks(holdDuration.Ticks / 2); 72 | var state = new StateBag() { PerformingContext = HangfireSubscriber.Value, TimeSpan = holdDuration }; 73 | _slidingExpirationTimer = new Timer(ExpirationTimerTick, state, halfLockHoldDuration, halfLockHoldDuration); 74 | } 75 | } 76 | 77 | private void ExpirationTimerTick(object state) 78 | { 79 | if (!_isDisposed) 80 | { 81 | var stateBag = state as StateBag; 82 | Exception redisEx = null; 83 | bool lockSuccesfullyExtended = false; 84 | int retryCount = 10; 85 | while (!lockSuccesfullyExtended && retryCount >= 0) 86 | { 87 | try 88 | { 89 | lockSuccesfullyExtended = _redis.LockExtend(_key, OwnerId, stateBag.TimeSpan); 90 | } 91 | catch (Exception ex) 92 | { 93 | redisEx = ex; 94 | Thread.Sleep(3000); 95 | retryCount--; 96 | } 97 | } 98 | 99 | if (!lockSuccesfullyExtended) 100 | { 101 | new BackgroundJobClient(stateBag.PerformingContext.Storage) 102 | .ChangeState( 103 | stateBag.PerformingContext.BackgroundJob.Id, 104 | new FailedState(new Exception($"Unable to extend a distributed lock with Key {_key} and OwnerId {OwnerId}", redisEx)) 105 | ); 106 | } 107 | } 108 | } 109 | 110 | public void Dispose() 111 | { 112 | if (_holdsLock) 113 | { 114 | _isDisposed = true; 115 | _slidingExpirationTimer.Dispose(); 116 | 117 | if (!_redis.LockRelease(_key, OwnerId)) 118 | { 119 | Debug.WriteLine("Lock {0} already timed out", _key); 120 | } 121 | 122 | HeldLocks.TryRemove(_key, out _); 123 | } 124 | } 125 | 126 | public static IDisposable Acquire([NotNull] IDatabase redis, RedisKey key, TimeSpan timeOut) 127 | { 128 | return Acquire(redis, key, timeOut, DefaultHoldDuration); 129 | } 130 | 131 | internal static IDisposable Acquire([NotNull] IDatabase redis, RedisKey key, TimeSpan timeOut, TimeSpan holdDuration) 132 | { 133 | if (redis == null) 134 | throw new ArgumentNullException(nameof(redis)); 135 | 136 | if (HeldLocks.ContainsKey(key)) 137 | { 138 | // lock is already held 139 | return new RedisLock(redis, key, false, holdDuration); 140 | } 141 | 142 | // The comparison below uses timeOut as a max timeSpan in waiting Lock 143 | var i = 0; 144 | var lockExpirationTime = DateTime.UtcNow + timeOut; 145 | do 146 | { 147 | if (redis.LockTake(key, OwnerId, holdDuration)) 148 | { 149 | // we have successfully acquired the lock 150 | return new RedisLock(redis, key, true, holdDuration); 151 | } 152 | 153 | SleepBackOffMultiplier(i++, (int)(lockExpirationTime - DateTime.UtcNow).TotalMilliseconds); 154 | } 155 | while (DateTime.UtcNow < lockExpirationTime); 156 | 157 | throw new DistributedLockTimeoutException($"Failed to acquire lock on {key} within given timeout ({timeOut})"); 158 | } 159 | 160 | private static void SleepBackOffMultiplier(int i, int maxWait) 161 | { 162 | if (maxWait <= 0) return; 163 | 164 | // exponential/random retry back-off. 165 | var rand = new Random(Guid.NewGuid().GetHashCode()); 166 | var nextTry = rand.Next( 167 | (int)Math.Pow(i, 2), (int)Math.Pow(i + 1, 2) + 1); 168 | 169 | nextTry = Math.Min(nextTry, maxWait); 170 | 171 | Thread.Sleep(nextTry); 172 | } 173 | } 174 | } 175 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Hangfire.Redis.StackExchange 2 | 3 | HangFire Redis storage based on [HangFire.Redis](https://github.com/HangfireIO/Hangfire.Redis/) but using lovely [StackExchange.Redis](https://github.com/StackExchange/StackExchange.Redis) client. 4 | 5 | [![Build status](https://ci.appveyor.com/api/projects/status/mrg1hivw1fnrvw2o?svg=true)](https://ci.appveyor.com/project/marcoCasamento/hangfire-redis-stackexchange) 6 | [![Nuget Badge](https://img.shields.io/nuget/dt/Hangfire.Redis.StackExchange)](https://www.nuget.org/packages/Hangfire.Redis.StackExchange/) 7 | 8 | | Package Name | NuGet.org | 9 | |-----------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------| 10 | | `Hangfire.Redis.StackExchange` | [![Nuget Badge](https://img.shields.io/nuget/v/Hangfire.Redis.StackExchange.svg)](https://www.nuget.org/packages/Hangfire.Redis.StackExchange/) | 11 | | `Hangfire.Redis.StackExchange.StrongName` | [![Nuget Badge](https://img.shields.io/nuget/v/Hangfire.Redis.StackExchange.StrongName.svg)](https://www.nuget.org/packages/Hangfire.Redis.StackExchange.StrongName/) | 12 | 13 | #### Highlights 14 | - Support for Hangfire Batches ([feature of Hangfire Pro](http://hangfire.io/blog/2015/04/17/hangfire-pro-1.2.0-released.html)) 15 | - Efficient use of Redis resources thanks to ConnectionMultiplexer 16 | - Support for Redis Prefix, allow multiple Hangfire Instances against a single Redis DB 17 | - Allow customization of Succeeded and Failed lists size 18 | 19 | > Despite the name, `Hangfire.Redis.StackExchange.StrongName` is **not signed** because `Hangfire.Core` is not yet signed. 20 | 21 | ## Tutorial: Hangfire on Redis on ASP.NET Core MVC 22 | 23 | ### Getting Started 24 | 25 | To use Hangfire against Redis in an ASP.NET Core MVC projects, first you will need to install at least these two packages: `Hangfire.AspNetCore` and `Hangfire.Redis.StackExchange`. 26 | 27 | In `Startup.cs`, these are the **bare minimum** codes that you will need to write for enabling Hangfire on Redis: 28 | 29 | ```cs 30 | public class Startup 31 | { 32 | public static ConnectionMultiplexer Redis; 33 | 34 | public Startup(IHostingEnvironment env) 35 | { 36 | // Other codes / configurations are omitted for brevity. 37 | Redis = ConnectionMultiplexer.Connect(Configuration.GetConnectionString("Redis")); 38 | } 39 | 40 | public void ConfigureServices(IServiceCollection services) 41 | { 42 | services.AddHangfire(configuration => 43 | { 44 | configuration.UseRedisStorage(Redis); 45 | }); 46 | } 47 | 48 | public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) 49 | { 50 | app.UseHangfireServer(); 51 | } 52 | } 53 | ``` 54 | 55 | > **Attention:** If you are using `Microsoft.Extensions.Caching.Redis` package, you will need to use `Hangfire.Redis.StackExchange.StrongName` instead, because the former package requires `StackExchange.Redis.StrongName` instead of `StackExchange.Redis`! 56 | 57 | ### Configurations 58 | 59 | #### configuration.UseRedisStorage(...) 60 | 61 | This method accepts two parameters: 62 | 63 | - The first parameter accepts either your Redis connection string or a `ConnectionMultiplexer` object. By [recommendation of the official StackExchange.Redis documentation](https://stackexchange.github.io/StackExchange.Redis/Basics), it is actually recommended to create one `ConnectionMultiplexer` for multiple reuse. 64 | 65 | - The second parameter accepts `RedisStorageOptions` object. As of version 1.7.0, these are the properties you can set into the said object: 66 | 67 | ```cs 68 | namespace Hangfire.Redis 69 | { 70 | public class RedisStorageOptions 71 | { 72 | public const string DefaultPrefix = "{hangfire}:"; 73 | 74 | public RedisStorageOptions(); 75 | 76 | public TimeSpan InvisibilityTimeout { get; set; } 77 | public TimeSpan FetchTimeout { get; set; } 78 | public string Prefix { get; set; } 79 | public int Db { get; set; } 80 | public int SucceededListSize { get; set; } 81 | public int DeletedListSize { get; set; } 82 | } 83 | } 84 | ``` 85 | 86 | > It is **highly recommended** to set the **Prefix** property, to avoid overlap with other projects that targets the same Redis store! 87 | 88 | #### app.UseHangfireServer(...) 89 | 90 | This method accepts `BackgroundJobServerOptions` as the first parameter: 91 | 92 | ```cs 93 | namespace Hangfire 94 | { 95 | public class BackgroundJobServerOptions 96 | { 97 | public BackgroundJobServerOptions(); 98 | 99 | public string ServerName { get; set; } 100 | public int WorkerCount { get; set; } 101 | public string[] Queues { get; set; } 102 | public TimeSpan ShutdownTimeout { get; set; } 103 | public TimeSpan SchedulePollingInterval { get; set; } 104 | public TimeSpan HeartbeatInterval { get; set; } 105 | public TimeSpan ServerTimeout { get; set; } 106 | public TimeSpan ServerCheckInterval { get; set; } 107 | public IJobFilterProvider FilterProvider { get; set; } 108 | public JobActivator Activator { get; set; } 109 | } 110 | } 111 | ``` 112 | 113 | Of these options, several interval options may be manually set (to longer intervals) to reduce CPU load: 114 | 115 | - `SchedulePollingInterval` is by default set to [15 seconds](http://docs.hangfire.io/en/latest/background-methods/calling-methods-with-delay.html). 116 | 117 | - `HeartbeatInterval` is by default set to [30 seconds](https://github.com/HangfireIO/Hangfire/blob/master/src/Hangfire.Core/Server/ServerHeartbeat.cs). 118 | 119 | - `ServerTimeout` and `ServerCheckInterval` is by default set to [5 minutes](https://github.com/HangfireIO/Hangfire/blob/master/src/Hangfire.Core/Server/ServerWatchdog.cs). 120 | 121 | ### Dashboard 122 | 123 | Written below is a short snippet on how to implement Hangfire dashboard in ASP.NET Core MVC applications, with limited access to cookie-authenticated users of `Administrator` role. [Read more in official documentation](http://docs.hangfire.io/en/latest/configuration/using-dashboard.html). 124 | 125 | ```cs 126 | public class AdministratorHangfireDashboardAuthorizationFilter : IDashboardAuthorizationFilter 127 | { 128 | public bool Authorize(DashboardContext context) 129 | { 130 | var user = context.GetHttpContext().User; 131 | return user.Identity.IsAuthenticated && user.IsInRole("Administrator"); 132 | } 133 | } 134 | ``` 135 | 136 | ```cs 137 | public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerFactory loggerFactory) 138 | { 139 | app.UseCookieAuthentication(...); 140 | 141 | // This middleware must be placed AFTER the authentication middlewares! 142 | app.UseHangfireDashboard(options: new DashboardOptions 143 | { 144 | Authorization = new[] { new AdministratorHangfireDashboardAuthorizationFilter() } 145 | }); 146 | } 147 | ``` 148 | 149 | ### Jobs via ASP.NET Core Dependency Injection Services 150 | 151 | For cleaner and more managable application code, it is possible to define your jobs in a class that is [registered via dependency injection](https://docs.microsoft.com/en-us/aspnet/core/fundamentals/dependency-injection). 152 | 153 | ```cs 154 | public class MyHangfireJobs 155 | { 156 | public async Task SendGetRequest() 157 | { 158 | var client = new HttpClient(); 159 | await client.GetAsync("https://www.accelist.com"); 160 | } 161 | } 162 | ``` 163 | 164 | ```cs 165 | public void ConfigureServices(IServiceCollection services) 166 | { 167 | services.AddTransient(); 168 | } 169 | ``` 170 | 171 | Using this technique, the registered jobs service will be able to obtain other services as dependency via constructor parameters, such as Entity Framework Core `DbContext`; which enables the development of powerful jobs with relative ease. 172 | 173 | Then later you can execute the jobs using generic expression: 174 | 175 | ```cs 176 | BackgroundJob.Enqueue(jobs => jobs.SendGetRequest()); 177 | 178 | BackgroundJob.Schedule(jobs => jobs.SendGetRequest(), DateTimeOffset.UtcNow.AddDays(1)); 179 | 180 | RecurringJob.AddOrUpdate("RecurringSendGetRequest", jobs => jobs.SendGetRequest(), Cron.Hourly()); 181 | ``` 182 | -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/RedisStorage.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | using System.Collections.Generic; 19 | using System.Linq; 20 | using Hangfire.Annotations; 21 | using Hangfire.Dashboard; 22 | using Hangfire.Logging; 23 | using Hangfire.Server; 24 | using Hangfire.States; 25 | using Hangfire.Storage; 26 | using StackExchange.Redis; 27 | 28 | namespace Hangfire.Redis.StackExchange 29 | { 30 | public class RedisStorage : JobStorage 31 | { 32 | // Make sure in Redis Cluster all transaction are in the same slot !! 33 | private readonly RedisStorageOptions _options; 34 | private readonly IConnectionMultiplexer _connectionMultiplexer; 35 | private readonly RedisSubscription _subscription; 36 | private readonly ConfigurationOptions _redisOptions; 37 | 38 | private readonly Dictionary _features = 39 | new Dictionary(StringComparer.OrdinalIgnoreCase) 40 | { 41 | { JobStorageFeatures.ExtendedApi, true }, 42 | { JobStorageFeatures.JobQueueProperty, true }, 43 | { JobStorageFeatures.Connection.BatchedGetFirstByLowest, true }, 44 | { JobStorageFeatures.Connection.GetUtcDateTime, true }, 45 | { JobStorageFeatures.Connection.GetSetContains, true }, 46 | { JobStorageFeatures.Connection.LimitedGetSetCount, true }, 47 | { JobStorageFeatures.Transaction.AcquireDistributedLock, true }, 48 | { JobStorageFeatures.Transaction.CreateJob, true }, // overridden in constructor 49 | { JobStorageFeatures.Transaction.SetJobParameter, true}, // overridden in constructor 50 | { JobStorageFeatures.Transaction.RemoveFromQueue(typeof(RedisFetchedJob)), true }, // overridden in constructor 51 | { JobStorageFeatures.Monitoring.DeletedStateGraphs, true }, 52 | { JobStorageFeatures.Monitoring.AwaitingJobs, true } 53 | }; 54 | 55 | public RedisStorage() 56 | : this("localhost:6379", null, null) 57 | { 58 | } 59 | 60 | public RedisStorage(IConnectionMultiplexer connectionMultiplexer, RedisStorageOptions options = null) 61 | : this("UseConnectionMultiplexer", connectionMultiplexer, options) 62 | { 63 | } 64 | 65 | public RedisStorage(string connectionString, RedisStorageOptions options = null) 66 | : this(connectionString, null, options) 67 | { 68 | } 69 | 70 | private RedisStorage(string connectionString, IConnectionMultiplexer connectionMultiplexer, 71 | RedisStorageOptions options = null) 72 | { 73 | if (connectionString == null) 74 | throw new ArgumentNullException(nameof(connectionString)); 75 | if (connectionString == "UseConnectionMultiplexer" && connectionMultiplexer == null) 76 | throw new ArgumentNullException(nameof(connectionMultiplexer)); 77 | 78 | _connectionMultiplexer = connectionMultiplexer ?? ConnectionMultiplexer.Connect(connectionString); 79 | 80 | _redisOptions = ConfigurationOptions.Parse(_connectionMultiplexer.Configuration); 81 | 82 | _options = options ?? new RedisStorageOptions 83 | { 84 | Db = _redisOptions.DefaultDatabase ?? 0 85 | }; 86 | 87 | SetTransactionalFeatures(); 88 | 89 | _subscription = new RedisSubscription(this, _connectionMultiplexer.GetSubscriber()); 90 | } 91 | 92 | private void SetTransactionalFeatures() 93 | { 94 | _features[JobStorageFeatures.Transaction.CreateJob] = _options.UseTransactions; 95 | _features[JobStorageFeatures.Transaction.SetJobParameter] = _options.UseTransactions; 96 | _features[JobStorageFeatures.Transaction.RemoveFromQueue(typeof(RedisFetchedJob))] = _options.UseTransactions; 97 | } 98 | 99 | public string ConnectionString => _connectionMultiplexer.Configuration; 100 | 101 | public int Db => _options.Db; 102 | 103 | internal int SucceededListSize => _options.SucceededListSize; 104 | 105 | internal int DeletedListSize => _options.DeletedListSize; 106 | 107 | internal RedisChannel SubscriptionChannel => _subscription.Channel; 108 | 109 | internal string[] LifoQueues => _options.LifoQueues; 110 | 111 | internal bool UseTransactions => _options.UseTransactions; 112 | 113 | public override IMonitoringApi GetMonitoringApi() 114 | { 115 | return new RedisMonitoringApi(this, _connectionMultiplexer.GetDatabase(Db)); 116 | } 117 | public override bool HasFeature([NotNull] string featureId) 118 | { 119 | if (featureId == null) throw new ArgumentNullException(nameof(featureId)); 120 | 121 | return _features.TryGetValue(featureId, out var isSupported) 122 | ? isSupported 123 | : base.HasFeature(featureId); 124 | 125 | } 126 | public override IStorageConnection GetConnection() 127 | { 128 | var endPoints = _connectionMultiplexer.GetEndPoints(false); 129 | IServer server = endPoints.Select(endPoint => _connectionMultiplexer.GetServer(endPoint)) 130 | .FirstOrDefault(s => s.IsConnected && !s.IsReplica); 131 | 132 | if (server == null) 133 | throw new RedisConnectionException(ConnectionFailureType.UnableToConnect, "No redis server available"); 134 | 135 | return new RedisConnection(this, server, _connectionMultiplexer.GetDatabase(Db), _subscription, _options.FetchTimeout); 136 | } 137 | 138 | #pragma warning disable 618 139 | public override IEnumerable GetComponents() 140 | #pragma warning restore 618 141 | { 142 | yield return new FetchedJobsWatcher(this, _options.InvisibilityTimeout); 143 | yield return new ExpiredJobsWatcher(this, _options.ExpiryCheckInterval); 144 | yield return _subscription; 145 | } 146 | 147 | public static DashboardMetric GetDashboardMetricFromRedisInfo(string title, string key) 148 | { 149 | return new DashboardMetric("redis:" + key, title, (razorPage) => 150 | { 151 | using (var redisCnn = razorPage.Storage.GetConnection()) 152 | { 153 | var db = (redisCnn as RedisConnection).Redis; 154 | var cnnMultiplexer = db.Multiplexer; 155 | var srv = cnnMultiplexer.GetServer(db.IdentifyEndpoint()); 156 | var rawInfo = srv.InfoRaw().Split('\n') 157 | .Where(x => x.Contains(':')) 158 | .ToDictionary(x => x.Split(':')[0], x => x.Split(':')[1]); 159 | 160 | return new Metric(rawInfo[key]); 161 | } 162 | }); 163 | } 164 | 165 | public override IEnumerable GetStateHandlers() 166 | { 167 | yield return new FailedStateHandler(); 168 | yield return new ProcessingStateHandler(); 169 | yield return new SucceededStateHandler(); 170 | yield return new DeletedStateHandler(); 171 | } 172 | 173 | public override void WriteOptionsToLog(ILog logger) 174 | { 175 | logger.Debug("Using the following options for Redis job storage:"); 176 | 177 | var connectionString = _redisOptions.ToString(includePassword: false); 178 | logger.DebugFormat("ConnectionString: {0}\nDN: {1}", connectionString, Db); 179 | } 180 | 181 | public override string ToString() 182 | { 183 | var connectionString = _redisOptions.ToString(includePassword: false); 184 | return string.Format("redis://{0}/{1}", connectionString, Db); 185 | } 186 | 187 | internal string GetRedisKey([NotNull] string key) 188 | { 189 | if (key == null) throw new ArgumentNullException(nameof(key)); 190 | 191 | return _options.Prefix + key; 192 | } 193 | } 194 | } 195 | 196 | -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/RedisConnectionFacts.cs: -------------------------------------------------------------------------------- 1 | using StackExchange.Redis; 2 | using System; 3 | using System.Collections.Generic; 4 | using Hangfire.Redis.StackExchange; 5 | using Hangfire.Redis.Tests.Utils; 6 | using Xunit; 7 | 8 | namespace Hangfire.Redis.Tests 9 | { 10 | [Collection("Sequential")] 11 | public class RedisConnectionFacts 12 | { 13 | private readonly RedisStorage _storage; 14 | 15 | public RedisConnectionFacts() 16 | { 17 | var options = new RedisStorageOptions() {Db = RedisUtils.GetDb()}; 18 | _storage = new RedisStorage(RedisUtils.GetHostAndPort(), options); 19 | } 20 | 21 | [Fact, CleanRedis] 22 | public void GetStateData_ThrowsAnException_WhenJobIdIsNull() 23 | { 24 | UseConnection( 25 | connection => Assert.Throws("jobId", 26 | () => connection.GetStateData(null))); 27 | } 28 | 29 | [Fact, CleanRedis] 30 | public void GetStateData_ReturnsNull_WhenJobDoesNotExist() 31 | { 32 | UseConnection(connection => 33 | { 34 | var result = connection.GetStateData("random-id"); 35 | Assert.Null(result); 36 | }); 37 | } 38 | 39 | [Fact, CleanRedis] 40 | public void GetStateData_ReturnsCorrectResult() 41 | { 42 | UseConnections((redis, connection) => 43 | { 44 | redis.HashSet( 45 | "{hangfire}:job:my-job:state", 46 | new Dictionary 47 | { 48 | {"State", "Name"}, 49 | {"Reason", "Reason"}, 50 | {"Key", "Value"} 51 | }.ToHashEntries()); 52 | 53 | var result = connection.GetStateData("my-job"); 54 | 55 | Assert.NotNull(result); 56 | Assert.Equal("Name", result.Name); 57 | Assert.Equal("Reason", result.Reason); 58 | Assert.Equal("Value", result.Data["Key"]); 59 | }); 60 | } 61 | 62 | [Fact, CleanRedis] 63 | public void GetStateData_ReturnsNullReason_IfThereIsNoSuchKey() 64 | { 65 | UseConnections((redis, connection) => 66 | { 67 | redis.HashSet( 68 | "{hangfire}:job:my-job:state", 69 | new Dictionary 70 | { 71 | {"State", "Name"} 72 | }.ToHashEntries()); 73 | 74 | var result = connection.GetStateData("my-job"); 75 | 76 | Assert.NotNull(result); 77 | Assert.Null(result.Reason); 78 | }); 79 | } 80 | 81 | [Fact, CleanRedis] 82 | public void GetAllItemsFromSet_ThrowsAnException_WhenKeyIsNull() 83 | { 84 | UseConnection(connection => 85 | Assert.Throws("key", 86 | () => connection.GetAllItemsFromSet(null))); 87 | } 88 | 89 | [Fact, CleanRedis] 90 | public void GetAllItemsFromSet_ReturnsEmptyCollection_WhenSetDoesNotExist() 91 | { 92 | UseConnection(connection => 93 | { 94 | var result = connection.GetAllItemsFromSet("some-set"); 95 | 96 | Assert.NotNull(result); 97 | Assert.Empty(result); 98 | }); 99 | } 100 | 101 | [Fact, CleanRedis] 102 | public void GetAllItemsFromSet_ReturnsAllItems() 103 | { 104 | UseConnections((redis, connection) => 105 | { 106 | // Arrange 107 | redis.SortedSetAdd("{hangfire}:some-set", "1", 0); 108 | redis.SortedSetAdd("{hangfire}:some-set", "2", 0); 109 | 110 | // Act 111 | var result = connection.GetAllItemsFromSet("some-set"); 112 | 113 | // Assert 114 | Assert.Equal(2, result.Count); 115 | Assert.Contains("1", result); 116 | Assert.Contains("2", result); 117 | }); 118 | } 119 | 120 | [Fact, CleanRedis] 121 | public void SetRangeInHash_ThrowsAnException_WhenKeyIsNull() 122 | { 123 | UseConnection(connection => 124 | { 125 | Assert.Throws("key", 126 | () => connection.SetRangeInHash(null, new Dictionary())); 127 | }); 128 | } 129 | 130 | [Fact, CleanRedis] 131 | public void SetRangeInHash_ThrowsAnException_WhenKeyValuePairsArgumentIsNull() 132 | { 133 | UseConnection(connection => 134 | { 135 | Assert.Throws("keyValuePairs", 136 | () => connection.SetRangeInHash("some-hash", null)); 137 | }); 138 | } 139 | 140 | [Fact, CleanRedis] 141 | public void SetRangeInHash_SetsAllGivenKeyPairs() 142 | { 143 | UseConnections((redis, connection) => 144 | { 145 | connection.SetRangeInHash("some-hash", new Dictionary 146 | { 147 | {"Key1", "Value1"}, 148 | {"Key2", "Value2"} 149 | }); 150 | 151 | var hash = redis.HashGetAll("{hangfire}:some-hash").ToStringDictionary(); 152 | Assert.Equal("Value1", hash["Key1"]); 153 | Assert.Equal("Value2", hash["Key2"]); 154 | }); 155 | } 156 | 157 | [Fact, CleanRedis] 158 | public void GetAllEntriesFromHash_ThrowsAnException_WhenKeyIsNull() 159 | { 160 | UseConnection(connection => 161 | Assert.Throws(() => connection.GetAllEntriesFromHash(null))); 162 | } 163 | 164 | [Fact, CleanRedis] 165 | public void GetAllEntriesFromHash_ReturnsNullValue_WhenHashDoesNotExist() 166 | { 167 | UseConnection(connection => 168 | { 169 | var result = connection.GetAllEntriesFromHash("some-hash"); 170 | Assert.Null(result); 171 | }); 172 | } 173 | 174 | [Fact, CleanRedis] 175 | public void GetAllEntriesFromHash_ReturnsAllEntries() 176 | { 177 | UseConnections((redis, connection) => 178 | { 179 | // Arrange 180 | redis.HashSet("{hangfire}:some-hash", new Dictionary 181 | { 182 | {"Key1", "Value1"}, 183 | {"Key2", "Value2"} 184 | }.ToHashEntries()); 185 | 186 | // Act 187 | var result = connection.GetAllEntriesFromHash("some-hash"); 188 | 189 | // Assert 190 | Assert.NotNull(result); 191 | Assert.Equal("Value1", result["Key1"]); 192 | Assert.Equal("Value2", result["Key2"]); 193 | }); 194 | } 195 | 196 | [Fact] 197 | public void GetUtcDateTime_ReturnsValidDateTime() 198 | { 199 | UseConnections((redis, connection) => 200 | { 201 | var result = connection.GetUtcDateTime(); 202 | 203 | Assert.NotEqual(default, result); 204 | }); 205 | } 206 | 207 | [Fact] 208 | public void SetCount_ReturnZeroIfSetDoesNotExists() 209 | { 210 | UseConnections((redis, connection) => 211 | { 212 | var result = connection.GetSetCount("some-set"); 213 | 214 | Assert.Equal(0, result); 215 | }); 216 | } 217 | 218 | [Fact, CleanRedis] 219 | public void SetCount_ReturnNumberOfItems() 220 | { 221 | UseConnections((redis, connection) => 222 | { 223 | redis.SortedSetAdd("{hangfire}:some-set", "1", 0); 224 | redis.SortedSetAdd("{hangfire}:some-set", "2", 0); 225 | 226 | var result = connection.GetSetCount("some-set"); 227 | 228 | Assert.Equal(2, result); 229 | }); 230 | } 231 | 232 | [Fact, CleanRedis] 233 | public void SetContains_ReturnTrueIfContained() 234 | { 235 | UseConnections((redis, connection) => 236 | { 237 | redis.SortedSetAdd("{hangfire}:some-set", "1", 0); 238 | 239 | var result = connection.GetSetContains("some-set", "1"); 240 | 241 | Assert.True(result); 242 | }); 243 | } 244 | 245 | [Fact, CleanRedis] 246 | public void SetContains_ReturnFalseIfNotContained() 247 | { 248 | UseConnections((redis, connection) => 249 | { 250 | redis.SortedSetAdd("{hangfire}:some-set", "1", 0); 251 | 252 | var result = connection.GetSetContains("some-set", "0"); 253 | 254 | Assert.False(result); 255 | }); 256 | } 257 | 258 | private void UseConnections(Action action) 259 | { 260 | var redis = RedisUtils.CreateClient(); 261 | var subscription = new RedisSubscription(_storage, RedisUtils.CreateSubscriber()); 262 | var server = RedisUtils.GetFirstServer(); 263 | using (var connection = new RedisConnection(_storage, server, redis, subscription, new RedisStorageOptions().FetchTimeout)) 264 | { 265 | action(redis, connection); 266 | } 267 | } 268 | 269 | private void UseConnection(Action action) 270 | { 271 | var redis = RedisUtils.CreateClient(); 272 | var subscription = new RedisSubscription(_storage, RedisUtils.CreateSubscriber()); 273 | var server = RedisUtils.GetFirstServer(); 274 | 275 | using (var connection = new RedisConnection(_storage, server, redis, subscription, new RedisStorageOptions().FetchTimeout)) 276 | { 277 | action(connection); 278 | } 279 | } 280 | 281 | } 282 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/RedisWriteDirectlyToDatabase.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | using System.Collections.Generic; 19 | using System.Linq; 20 | using System.Threading.Tasks; 21 | using Hangfire.Annotations; 22 | using Hangfire.Common; 23 | using Hangfire.States; 24 | using Hangfire.Storage; 25 | using StackExchange.Redis; 26 | 27 | namespace Hangfire.Redis.StackExchange 28 | { 29 | internal class RedisWriteDirectlyToDatabase : JobStorageTransaction 30 | { 31 | private readonly RedisStorage _storage; 32 | private readonly IDatabase _database; 33 | 34 | public RedisWriteDirectlyToDatabase([NotNull] RedisStorage storage, [NotNull] IDatabase database) 35 | { 36 | _storage = storage ?? throw new ArgumentNullException(nameof(storage)); 37 | _database = database ?? throw new ArgumentNullException(nameof(database)); 38 | } 39 | 40 | public override void AddRangeToSet([NotNull] string key, [NotNull] IList items) 41 | { 42 | _database.SortedSetAdd(_storage.GetRedisKey(key), items.Select(x => new SortedSetEntry(x, 0)).ToArray()); 43 | } 44 | 45 | public override void ExpireHash([NotNull] string key, TimeSpan expireIn) 46 | { 47 | _database.KeyExpire(_storage.GetRedisKey(key), expireIn); 48 | } 49 | 50 | public override void ExpireList([NotNull] string key, TimeSpan expireIn) 51 | { 52 | _database.KeyExpire(_storage.GetRedisKey(key), expireIn); 53 | } 54 | 55 | public override void ExpireSet([NotNull] string key, TimeSpan expireIn) 56 | { 57 | _database.KeyExpire(_storage.GetRedisKey(key), expireIn); 58 | } 59 | 60 | public override void PersistHash([NotNull] string key) 61 | { 62 | _database.KeyPersist(_storage.GetRedisKey(key)); 63 | } 64 | 65 | public override void PersistList([NotNull] string key) 66 | { 67 | _database.KeyPersist(_storage.GetRedisKey(key)); 68 | } 69 | 70 | public override void PersistSet([NotNull] string key) 71 | { 72 | _database.KeyPersist(_storage.GetRedisKey(key)); 73 | } 74 | 75 | public override void RemoveSet([NotNull] string key) 76 | { 77 | _database.KeyDelete(_storage.GetRedisKey(key)); 78 | } 79 | 80 | public override void Commit() 81 | { 82 | //nothing to be done 83 | } 84 | 85 | public override void ExpireJob([NotNull] string jobId, TimeSpan expireIn) 86 | { 87 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 88 | 89 | var tasks = new Task[3]; 90 | 91 | tasks[0] = _database.KeyExpireAsync(_storage.GetRedisKey($"job:{jobId}"), expireIn); 92 | tasks[1] = _database.KeyExpireAsync(_storage.GetRedisKey($"job:{jobId}:history"), expireIn); 93 | tasks[2] = _database.KeyExpireAsync(_storage.GetRedisKey($"job:{jobId}:state"), expireIn); 94 | 95 | Task.WaitAll(tasks); 96 | } 97 | 98 | public override void PersistJob([NotNull] string jobId) 99 | { 100 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 101 | 102 | var tasks = new Task[3]; 103 | 104 | tasks[0] = _database.KeyPersistAsync(_storage.GetRedisKey($"job:{jobId}")); 105 | tasks[1] = _database.KeyPersistAsync(_storage.GetRedisKey($"job:{jobId}:history")); 106 | tasks[2] = _database.KeyPersistAsync(_storage.GetRedisKey($"job:{jobId}:state")); 107 | 108 | Task.WaitAll(tasks); 109 | } 110 | 111 | public override void SetJobState([NotNull] string jobId, [NotNull] IState state) 112 | { 113 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 114 | if (state == null) throw new ArgumentNullException(nameof(state)); 115 | 116 | var tasks = new Task[3]; 117 | 118 | tasks[0] = _database.HashSetAsync(_storage.GetRedisKey($"job:{jobId}"), "State", state.Name); 119 | tasks[1] = _database.KeyDeleteAsync(_storage.GetRedisKey($"job:{jobId}:state")); 120 | 121 | var storedData = new Dictionary(state.SerializeData()) 122 | { 123 | {"State", state.Name} 124 | }; 125 | 126 | if (state.Reason != null) 127 | storedData.Add("Reason", state.Reason); 128 | 129 | tasks[2] = _database.HashSetAsync(_storage.GetRedisKey($"job:{jobId}:state"), storedData.ToHashEntries()); 130 | 131 | AddJobState(jobId, state); 132 | 133 | Task.WaitAll(tasks); 134 | } 135 | 136 | public override void AddJobState([NotNull] string jobId, [NotNull] IState state) 137 | { 138 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 139 | if (state == null) throw new ArgumentNullException(nameof(state)); 140 | 141 | var storedData = new Dictionary(state.SerializeData()) 142 | { 143 | {"State", state.Name}, 144 | {"Reason", state.Reason}, 145 | {"CreatedAt", JobHelper.SerializeDateTime(DateTime.UtcNow)} 146 | }; 147 | 148 | _database.ListRightPush( 149 | _storage.GetRedisKey($"job:{jobId}:history"), 150 | SerializationHelper.Serialize(storedData)); 151 | } 152 | 153 | public override void AddToQueue([NotNull] string queue, [NotNull] string jobId) 154 | { 155 | if (queue == null) throw new ArgumentNullException(nameof(queue)); 156 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 157 | 158 | var tasks = new Task[3]; 159 | 160 | tasks[0] = _database.SetAddAsync(_storage.GetRedisKey("queues"), queue); 161 | if (_storage.LifoQueues != null && _storage.LifoQueues.Contains(queue, StringComparer.OrdinalIgnoreCase)) 162 | { 163 | tasks[1] = _database.ListRightPushAsync(_storage.GetRedisKey($"queue:{queue}"), jobId); 164 | } 165 | else 166 | { 167 | tasks[1] = _database.ListLeftPushAsync(_storage.GetRedisKey($"queue:{queue}"), jobId); 168 | } 169 | 170 | tasks[2] = _database.PublishAsync(_storage.SubscriptionChannel, jobId); 171 | 172 | Task.WaitAll(tasks); 173 | } 174 | 175 | public override void IncrementCounter([NotNull] string key) 176 | { 177 | _database.StringIncrement(_storage.GetRedisKey(key)); 178 | } 179 | 180 | public override void IncrementCounter([NotNull] string key, TimeSpan expireIn) 181 | { 182 | var tasks = new Task[2]; 183 | 184 | tasks[0] = _database.StringIncrementAsync(_storage.GetRedisKey(key)); 185 | tasks[1] = _database.KeyExpireAsync(_storage.GetRedisKey(key), expireIn); 186 | 187 | Task.WaitAll(tasks); 188 | } 189 | 190 | public override void DecrementCounter([NotNull] string key) 191 | { 192 | _database.StringDecrement(_storage.GetRedisKey(key)); 193 | } 194 | 195 | public override void DecrementCounter([NotNull] string key, TimeSpan expireIn) 196 | { 197 | var tasks = new Task[2]; 198 | 199 | tasks[0] = _database.StringDecrementAsync(_storage.GetRedisKey(key)); 200 | tasks[1] = _database.KeyExpireAsync(_storage.GetRedisKey(key), expireIn); 201 | 202 | Task.WaitAll(tasks); 203 | } 204 | 205 | public override void AddToSet([NotNull] string key, [NotNull] string value) 206 | { 207 | AddToSet(key, value, 0); 208 | } 209 | 210 | public override void AddToSet([NotNull] string key, [NotNull] string value, double score) 211 | { 212 | if (value == null) throw new ArgumentNullException(nameof(value)); 213 | 214 | _database.SortedSetAdd(_storage.GetRedisKey(key), value, score); 215 | } 216 | 217 | public override void RemoveFromSet([NotNull] string key, [NotNull] string value) 218 | { 219 | if (value == null) throw new ArgumentNullException(nameof(value)); 220 | 221 | _database.SortedSetRemove(_storage.GetRedisKey(key), value); 222 | } 223 | 224 | public override void InsertToList([NotNull] string key, string value) 225 | { 226 | _database.ListLeftPush(_storage.GetRedisKey(key), value); 227 | } 228 | 229 | public override void RemoveFromList([NotNull] string key, string value) 230 | { 231 | _database.ListRemove(_storage.GetRedisKey(key), value); 232 | } 233 | 234 | public override void TrimList([NotNull] string key, int keepStartingFrom, int keepEndingAt) 235 | { 236 | _database.ListTrim(_storage.GetRedisKey(key), keepStartingFrom, keepEndingAt); 237 | } 238 | 239 | public override void SetRangeInHash( 240 | [NotNull] string key, [NotNull] IEnumerable> keyValuePairs) 241 | { 242 | if (keyValuePairs == null) throw new ArgumentNullException(nameof(keyValuePairs)); 243 | 244 | _database.HashSet(_storage.GetRedisKey(key), keyValuePairs.ToHashEntries()); 245 | } 246 | 247 | public override void RemoveHash([NotNull] string key) 248 | { 249 | _database.KeyDelete(_storage.GetRedisKey(key)); 250 | } 251 | 252 | public override void Dispose() 253 | { 254 | //Don't have to dispose anything 255 | } 256 | } 257 | } -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/RedisFetchedJobFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Hangfire.Common; 3 | using Hangfire.Redis.StackExchange; 4 | using Hangfire.Redis.Tests.Utils; 5 | using Moq; 6 | using Xunit; 7 | using StackExchange.Redis; 8 | 9 | namespace Hangfire.Redis.Tests 10 | { 11 | [Collection("Sequential")] 12 | public class RedisFetchedJobFacts 13 | { 14 | private const string JobId = "id"; 15 | private const string Queue = "queue"; 16 | 17 | private readonly RedisStorage _storage; 18 | private readonly Mock _redis; 19 | 20 | public RedisFetchedJobFacts() 21 | { 22 | _redis = new Mock(); 23 | 24 | var options = new RedisStorageOptions() { Db = RedisUtils.GetDb() }; 25 | _storage = new RedisStorage(RedisUtils.GetHostAndPort(), options); 26 | } 27 | 28 | [Fact] 29 | public void Ctor_ThrowsAnException_WhenStorageIsNull() 30 | { 31 | Assert.Throws("storage", 32 | () => new RedisFetchedJob(null, _redis.Object, JobId, Queue, DateTime.UtcNow)); 33 | } 34 | 35 | [Fact] 36 | public void Ctor_ThrowsAnException_WhenRedisIsNull() 37 | { 38 | Assert.Throws("redis", 39 | () => new RedisFetchedJob(_storage, null, JobId, Queue, DateTime.UtcNow)); 40 | } 41 | 42 | [Fact] 43 | public void Ctor_ThrowsAnException_WhenJobIdIsNull() 44 | { 45 | Assert.Throws("jobId", 46 | () => new RedisFetchedJob(_storage, _redis.Object, null, Queue, DateTime.UtcNow)); 47 | } 48 | 49 | [Fact] 50 | public void Ctor_ThrowsAnException_WhenQueueIsNull() 51 | { 52 | Assert.Throws("queue", 53 | () => new RedisFetchedJob(_storage, _redis.Object, JobId, null, DateTime.UtcNow)); 54 | } 55 | 56 | [Fact, CleanRedis] 57 | public void RemoveFromQueue_RemovesJobFromTheFetchedList() 58 | { 59 | UseRedis(redis => 60 | { 61 | // Arrange 62 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "job-id"); 63 | 64 | var fetchedAt = DateTime.UtcNow; 65 | redis.HashSet("{hangfire}:job:job-id", "Fetched", JobHelper.SerializeDateTime(fetchedAt)); 66 | var fetchedJob = new RedisFetchedJob(_storage, redis, "job-id", "my-queue", fetchedAt); 67 | 68 | // Act 69 | fetchedJob.RemoveFromQueue(); 70 | 71 | // Assert 72 | Assert.Equal(0, redis.ListLength("{hangfire}:queue:my-queue:dequeued")); 73 | }); 74 | } 75 | 76 | [Fact, CleanRedis] 77 | public void RemoveFromQueue_RemovesOnlyJobWithTheSpecifiedId() 78 | { 79 | UseRedis(redis => 80 | { 81 | // Arrange 82 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "job-id"); 83 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "another-job-id"); 84 | 85 | var fetchedAt = DateTime.UtcNow; 86 | redis.HashSet("{hangfire}:job:job-id", "Fetched", JobHelper.SerializeDateTime(fetchedAt)); 87 | redis.HashSet("{hangfire}:job:another-job-id", "Fetched", JobHelper.SerializeDateTime(fetchedAt)); 88 | var fetchedJob = new RedisFetchedJob(_storage, redis, "job-id", "my-queue", fetchedAt); 89 | 90 | // Act 91 | fetchedJob.RemoveFromQueue(); 92 | 93 | // Assert 94 | Assert.Equal(1, redis.ListLength("{hangfire}:queue:my-queue:dequeued")); 95 | Assert.Equal("another-job-id", (string)redis.ListRightPop("{hangfire}:queue:my-queue:dequeued")); 96 | }); 97 | } 98 | 99 | [Fact, CleanRedis] 100 | public void RemoveFromQueue_DoesNotRemoveIfFetchedDoesntMatch() 101 | { 102 | UseRedis(redis => 103 | { 104 | // Arrange 105 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "job-id"); 106 | 107 | var fetchedAt = DateTime.UtcNow; 108 | redis.HashSet("{hangfire}:job:job-id", "Fetched", JobHelper.SerializeDateTime(fetchedAt)); 109 | var fetchedJob = new RedisFetchedJob(_storage, redis, "job-id", "my-queue", fetchedAt + TimeSpan.FromSeconds(1)); 110 | 111 | // Act 112 | fetchedJob.RemoveFromQueue(); 113 | 114 | // Assert 115 | Assert.Equal(1, redis.ListLength("{hangfire}:queue:my-queue:dequeued")); 116 | Assert.Equal("job-id", (string)redis.ListRightPop("{hangfire}:queue:my-queue:dequeued")); 117 | }); 118 | } 119 | 120 | [Fact, CleanRedis] 121 | public void RemoveFromQueue_RemovesTheFetchedFlag() 122 | { 123 | UseRedis(redis => 124 | { 125 | // Arrange 126 | var fetchedAt = DateTime.UtcNow; 127 | redis.HashSet("{hangfire}:job:my-job", "Fetched", JobHelper.SerializeDateTime(fetchedAt)); 128 | var fetchedJob = new RedisFetchedJob(_storage, redis, "my-job", "my-queue", fetchedAt); 129 | 130 | // Act 131 | fetchedJob.RemoveFromQueue(); 132 | 133 | // Assert 134 | Assert.False(redis.HashExists("{hangfire}:job:my-job", "Fetched")); 135 | }); 136 | } 137 | 138 | [Fact, CleanRedis] 139 | public void RemoveFromQueue_RemovesTheCheckedFlag() 140 | { 141 | UseRedis(redis => 142 | { 143 | // Arrange 144 | redis.HashSet("{hangfire}:job:my-job", "Checked", JobHelper.SerializeDateTime(DateTime.UtcNow)); 145 | var fetchedJob = new RedisFetchedJob(_storage, redis, "my-job", "my-queue", null); 146 | 147 | // Act 148 | fetchedJob.RemoveFromQueue(); 149 | 150 | // Assert 151 | Assert.False(redis.HashExists("{hangfire}:job:my-job", "Checked")); 152 | }); 153 | } 154 | 155 | [Fact, CleanRedis] 156 | public void Requeue_PushesAJobBackToQueue() 157 | { 158 | UseRedis(redis => 159 | { 160 | // Arrange 161 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "my-job"); 162 | var fetchedAt = DateTime.UtcNow; 163 | redis.HashSet("{hangfire}:job:my-job", "Fetched", JobHelper.SerializeDateTime(fetchedAt)); 164 | 165 | var fetchedJob = new RedisFetchedJob(_storage, redis, "my-job", "my-queue", fetchedAt); 166 | 167 | // Act 168 | fetchedJob.Requeue(); 169 | 170 | // Assert 171 | Assert.Equal("my-job", (string)redis.ListRightPop("{hangfire}:queue:my-queue")); 172 | Assert.Null((string)redis.ListLeftPop("{hangfire}:queue:my-queue:dequeued")); 173 | }); 174 | } 175 | 176 | [Fact, CleanRedis] 177 | public void Requeue_PushesAJobToTheRightSide() 178 | { 179 | UseRedis(redis => 180 | { 181 | // Arrange 182 | redis.ListRightPush("{hangfire}:queue:my-queue", "another-job"); 183 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "my-job"); 184 | var fetchedAt = DateTime.UtcNow; 185 | redis.HashSet("{hangfire}:job:my-job", "Fetched", JobHelper.SerializeDateTime(fetchedAt)); 186 | 187 | 188 | var fetchedJob = new RedisFetchedJob(_storage, redis, "my-job", "my-queue", fetchedAt); 189 | 190 | // Act 191 | fetchedJob.Requeue(); 192 | 193 | // Assert - RPOP 194 | Assert.Equal("my-job", (string)redis.ListRightPop("{hangfire}:queue:my-queue")); 195 | Assert.Null((string)redis.ListRightPop("{hangfire}:queue:my-queue:dequeued")); 196 | }); 197 | } 198 | 199 | [Fact, CleanRedis] 200 | public void Requeue_RemovesAJobFromFetchedList() 201 | { 202 | UseRedis(redis => 203 | { 204 | // Arrange 205 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "my-job"); 206 | var fetchedAt = DateTime.UtcNow; 207 | redis.HashSet("{hangfire}:job:my-job", "Fetched", JobHelper.SerializeDateTime(fetchedAt)); 208 | 209 | var fetchedJob = new RedisFetchedJob(_storage, redis, "my-job", "my-queue", fetchedAt); 210 | 211 | // Act 212 | fetchedJob.Requeue(); 213 | 214 | // Assert 215 | Assert.Equal(0, redis.ListLength("{hangfire}:queue:my-queue:dequeued")); 216 | }); 217 | } 218 | 219 | [Fact, CleanRedis] 220 | public void Requeue_RemovesTheFetchedFlag() 221 | { 222 | UseRedis(redis => 223 | { 224 | // Arrange 225 | var fetchedAt = DateTime.UtcNow; 226 | redis.HashSet("{hangfire}:job:my-job", "Fetched", JobHelper.SerializeDateTime(fetchedAt)); 227 | var fetchedJob = new RedisFetchedJob(_storage, redis, "my-job", "my-queue", fetchedAt); 228 | 229 | // Act 230 | fetchedJob.Requeue(); 231 | 232 | // Assert 233 | Assert.False(redis.HashExists("{hangfire}:job:my-job", "Fetched")); 234 | }); 235 | } 236 | 237 | [Fact, CleanRedis] 238 | public void Requeue_RemovesTheCheckedFlag() 239 | { 240 | UseRedis(redis => 241 | { 242 | // Arrange 243 | redis.HashSet("{hangfire}:job:my-job", "Checked", JobHelper.SerializeDateTime(DateTime.UtcNow)); 244 | var fetchedJob = new RedisFetchedJob(_storage, redis, "my-job", "my-queue", null); 245 | 246 | // Act 247 | fetchedJob.Requeue(); 248 | 249 | // Assert 250 | Assert.False(redis.HashExists("{hangfire}:job:my-job", "Checked")); 251 | }); 252 | } 253 | 254 | [Fact, CleanRedis] 255 | public void Dispose_WithNoComplete_RequeuesAJob() 256 | { 257 | UseRedis(redis => 258 | { 259 | // Arrange 260 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "my-job"); 261 | var fetchedJob = new RedisFetchedJob(_storage, redis, "my-job", "my-queue", DateTime.UtcNow); 262 | 263 | // Act 264 | fetchedJob.Dispose(); 265 | 266 | // Assert 267 | Assert.Equal(1, redis.ListLength("{hangfire}:queue:my-queue")); 268 | }); 269 | } 270 | 271 | [Fact, CleanRedis] 272 | public void Dispose_AfterRemoveFromQueue_DoesNotRequeueAJob() 273 | { 274 | UseRedis(redis => 275 | { 276 | // Arrange 277 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "my-job"); 278 | redis.ListRightPush("{hangfire}:queue:my-queue:dequeued", "my-job"); 279 | var fetchedJob = new RedisFetchedJob(_storage, redis, "my-job", "my-queue", DateTime.UtcNow); 280 | 281 | // Act 282 | fetchedJob.RemoveFromQueue(); 283 | fetchedJob.Dispose(); 284 | 285 | // Assert 286 | Assert.Equal(0, redis.ListLength("{hangfire}:queue:my-queue")); 287 | }); 288 | } 289 | 290 | private static void UseRedis(Action action) 291 | { 292 | var redis = RedisUtils.CreateClient(); 293 | action(redis); 294 | } 295 | } 296 | } 297 | -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/RedisWriteOnlyTransaction.cs: -------------------------------------------------------------------------------- 1 | // Copyright © 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using System; 18 | using System.Collections.Generic; 19 | using System.Linq; 20 | using Hangfire.Annotations; 21 | using Hangfire.Common; 22 | using Hangfire.States; 23 | using Hangfire.Storage; 24 | using StackExchange.Redis; 25 | 26 | namespace Hangfire.Redis.StackExchange 27 | { 28 | internal class RedisWriteOnlyTransaction : JobStorageTransaction 29 | { 30 | private readonly RedisStorage _storage; 31 | private readonly ITransaction _transaction; 32 | private readonly List _lockToDispose = new List(); 33 | public RedisWriteOnlyTransaction([NotNull] RedisStorage storage, [NotNull] ITransaction transaction) 34 | { 35 | if (storage == null) throw new ArgumentNullException(nameof(storage)); 36 | if (transaction == null) throw new ArgumentNullException(nameof(transaction)); 37 | 38 | _storage = storage; 39 | _transaction = transaction; 40 | } 41 | 42 | public override void AddRangeToSet([NotNull] string key, [NotNull] IList items) 43 | { 44 | _transaction.SortedSetAddAsync(_storage.GetRedisKey(key), items.Select(x => new SortedSetEntry(x, 0)).ToArray()); 45 | } 46 | public override void AcquireDistributedLock([NotNull] string resource, TimeSpan timeout) 47 | { 48 | var distributedLock = _storage.GetConnection().AcquireDistributedLock(resource, timeout); 49 | _lockToDispose.Add(distributedLock); 50 | } 51 | public override void ExpireHash([NotNull] string key, TimeSpan expireIn) 52 | { 53 | _transaction.KeyExpireAsync(_storage.GetRedisKey(key), expireIn); 54 | } 55 | 56 | public override void ExpireList([NotNull] string key, TimeSpan expireIn) 57 | { 58 | _transaction.KeyExpireAsync(_storage.GetRedisKey(key), expireIn); 59 | } 60 | 61 | public override void ExpireSet([NotNull] string key, TimeSpan expireIn) 62 | { 63 | _transaction.KeyExpireAsync(_storage.GetRedisKey(key), expireIn); 64 | } 65 | 66 | public override void PersistHash([NotNull] string key) 67 | { 68 | _transaction.KeyPersistAsync(_storage.GetRedisKey(key)); 69 | } 70 | 71 | public override void PersistList([NotNull] string key) 72 | { 73 | _transaction.KeyPersistAsync(_storage.GetRedisKey(key)); 74 | } 75 | 76 | public override void PersistSet([NotNull] string key) 77 | { 78 | _transaction.KeyPersistAsync(_storage.GetRedisKey(key)); 79 | } 80 | 81 | public override void RemoveSet([NotNull] string key) 82 | { 83 | _transaction.KeyDeleteAsync(_storage.GetRedisKey(key)); 84 | } 85 | 86 | public override void Commit() 87 | { 88 | if (!_transaction.Execute()) 89 | { 90 | // RedisTransaction.Commit returns false only when 91 | // WATCH condition has been failed. So, we should 92 | // re-play the transaction. 93 | 94 | int replayCount = 1; 95 | const int maxReplayCount = 3; 96 | while (!_transaction.Execute()) 97 | { 98 | if (replayCount++ >= maxReplayCount) 99 | { 100 | throw new HangFireRedisException("Transaction commit was failed due to WATCH condition failure. Retry attempts exceeded."); 101 | } 102 | } 103 | } 104 | } 105 | 106 | public override void ExpireJob([NotNull] string jobId, TimeSpan expireIn) 107 | { 108 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 109 | 110 | _transaction.KeyExpireAsync(_storage.GetRedisKey($"job:{jobId}"), expireIn); 111 | _transaction.KeyExpireAsync(_storage.GetRedisKey($"job:{jobId}:history"), expireIn); 112 | _transaction.KeyExpireAsync(_storage.GetRedisKey($"job:{jobId}:state"), expireIn); 113 | } 114 | 115 | public override void PersistJob([NotNull] string jobId) 116 | { 117 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 118 | 119 | _transaction.KeyPersistAsync(_storage.GetRedisKey($"job:{jobId}")); 120 | _transaction.KeyPersistAsync(_storage.GetRedisKey($"job:{jobId}:history")); 121 | _transaction.KeyPersistAsync(_storage.GetRedisKey($"job:{jobId}:state")); 122 | } 123 | 124 | public override void SetJobState([NotNull] string jobId, [NotNull] IState state) 125 | { 126 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 127 | if (state == null) throw new ArgumentNullException(nameof(state)); 128 | 129 | _transaction.HashSetAsync(_storage.GetRedisKey($"job:{jobId}"), "State", state.Name); 130 | _transaction.KeyDeleteAsync(_storage.GetRedisKey($"job:{jobId}:state")); 131 | 132 | var storedData = new Dictionary(state.SerializeData()) 133 | { 134 | {"State", state.Name} 135 | }; 136 | 137 | if (state.Reason != null) 138 | storedData.Add("Reason", state.Reason); 139 | 140 | _transaction.HashSetAsync(_storage.GetRedisKey($"job:{jobId}:state"), storedData.ToHashEntries()); 141 | 142 | AddJobState(jobId, state); 143 | } 144 | 145 | public override void AddJobState([NotNull] string jobId, [NotNull] IState state) 146 | { 147 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 148 | if (state == null) throw new ArgumentNullException(nameof(state)); 149 | 150 | var storedData = new Dictionary(state.SerializeData()) 151 | { 152 | {"State", state.Name}, 153 | {"Reason", state.Reason}, 154 | {"CreatedAt", JobHelper.SerializeDateTime(DateTime.UtcNow)} 155 | }; 156 | 157 | _transaction.ListRightPushAsync( 158 | _storage.GetRedisKey($"job:{jobId}:history"), 159 | SerializationHelper.Serialize(storedData)); 160 | } 161 | 162 | public override void AddToQueue([NotNull] string queue, [NotNull] string jobId) 163 | { 164 | if (queue == null) throw new ArgumentNullException(nameof(queue)); 165 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 166 | 167 | _transaction.SetAddAsync(_storage.GetRedisKey("queues"), queue); 168 | if (_storage.LifoQueues != null && _storage.LifoQueues.Contains(queue, StringComparer.OrdinalIgnoreCase)) 169 | { 170 | _transaction.ListRightPushAsync(_storage.GetRedisKey($"queue:{queue}"), jobId); 171 | } 172 | else 173 | { 174 | _transaction.ListLeftPushAsync(_storage.GetRedisKey($"queue:{queue}"), jobId); 175 | } 176 | 177 | _transaction.PublishAsync(_storage.SubscriptionChannel, jobId); 178 | } 179 | 180 | public override void IncrementCounter([NotNull] string key) 181 | { 182 | _transaction.StringIncrementAsync(_storage.GetRedisKey(key)); 183 | } 184 | 185 | public override void IncrementCounter([NotNull] string key, TimeSpan expireIn) 186 | { 187 | _transaction.StringIncrementAsync(_storage.GetRedisKey(key)); 188 | _transaction.KeyExpireAsync(_storage.GetRedisKey(key), expireIn); 189 | } 190 | 191 | public override void DecrementCounter([NotNull] string key) 192 | { 193 | _transaction.StringDecrementAsync(_storage.GetRedisKey(key)); 194 | } 195 | 196 | public override void DecrementCounter([NotNull] string key, TimeSpan expireIn) 197 | { 198 | _transaction.StringDecrementAsync(_storage.GetRedisKey(key)); 199 | _transaction.KeyExpireAsync(_storage.GetRedisKey(key), expireIn); 200 | } 201 | 202 | public override void AddToSet([NotNull] string key, [NotNull] string value) 203 | { 204 | AddToSet(key, value, 0); 205 | } 206 | 207 | public override void AddToSet([NotNull] string key, [NotNull] string value, double score) 208 | { 209 | if (value == null) throw new ArgumentNullException(nameof(value)); 210 | 211 | _transaction.SortedSetAddAsync(_storage.GetRedisKey(key), value, score); 212 | } 213 | public override string CreateJob([NotNull] Job job, [NotNull] IDictionary parameters, DateTime createdAt, TimeSpan expireIn) 214 | { 215 | if (job == null) throw new ArgumentNullException(nameof(job)); 216 | if (parameters == null) throw new ArgumentNullException(nameof(parameters)); 217 | 218 | var jobId = Guid.NewGuid().ToString(); 219 | 220 | var invocationData = InvocationData.SerializeJob(job); 221 | 222 | // Do not modify the original parameters. 223 | var storedParameters = new Dictionary(parameters) 224 | { 225 | { "Queue", invocationData.Queue }, 226 | { "Type", invocationData.Type }, 227 | { "Method", invocationData.Method }, 228 | { "ParameterTypes", invocationData.ParameterTypes }, 229 | { "Arguments", invocationData.Arguments }, 230 | { "CreatedAt", JobHelper.SerializeDateTime(createdAt) } 231 | }; 232 | _ = _transaction.HashSetAsync(_storage.GetRedisKey($"job:{jobId}"), storedParameters.ToHashEntries()); 233 | _ = _transaction.KeyExpireAsync(_storage.GetRedisKey($"job:{jobId}"), expireIn); 234 | 235 | if (! _transaction.Execute()) 236 | throw new HangfireRedisTransactionException("Transaction Execution failure"); 237 | return jobId; 238 | } 239 | public override void RemoveFromSet([NotNull] string key, [NotNull] string value) 240 | { 241 | if (value == null) throw new ArgumentNullException(nameof(value)); 242 | 243 | _transaction.SortedSetRemoveAsync(_storage.GetRedisKey(key), value); 244 | } 245 | 246 | public override void InsertToList([NotNull] string key, string value) 247 | { 248 | _transaction.ListLeftPushAsync(_storage.GetRedisKey(key), value); 249 | } 250 | 251 | public override void RemoveFromList([NotNull] string key, string value) 252 | { 253 | _transaction.ListRemoveAsync(_storage.GetRedisKey(key), value); 254 | } 255 | 256 | public override void TrimList([NotNull] string key, int keepStartingFrom, int keepEndingAt) 257 | { 258 | _transaction.ListTrimAsync(_storage.GetRedisKey(key), keepStartingFrom, keepEndingAt); 259 | } 260 | 261 | public override void SetRangeInHash( 262 | [NotNull] string key, [NotNull] IEnumerable> keyValuePairs) 263 | { 264 | if (keyValuePairs == null) throw new ArgumentNullException(nameof(keyValuePairs)); 265 | 266 | _transaction.HashSetAsync(_storage.GetRedisKey(key), keyValuePairs.ToHashEntries()); 267 | } 268 | public override void SetJobParameter([NotNull] string jobId, [NotNull] string name, [CanBeNull] string value) 269 | { 270 | _storage.GetConnection().SetJobParameter(jobId, name, value); 271 | } 272 | public override void RemoveHash([NotNull] string key) 273 | { 274 | _transaction.KeyDeleteAsync(_storage.GetRedisKey(key)); 275 | } 276 | 277 | public override void RemoveFromQueue([NotNull] IFetchedJob fetchedJob) 278 | { 279 | fetchedJob.RemoveFromQueue(); 280 | } 281 | public override void Dispose() 282 | { 283 | //Don't have to dispose anything 284 | foreach (var lokc in _lockToDispose) 285 | { 286 | lokc.Dispose(); 287 | } 288 | } 289 | } 290 | } -------------------------------------------------------------------------------- /Hangfire.Redis.Tests/RedisWriteOnlyTransactionFacts.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using Hangfire.Common; 4 | using Hangfire.Redis.StackExchange; 5 | using Hangfire.Redis.Tests.Utils; 6 | using Hangfire.States; 7 | using Moq; 8 | using Xunit; 9 | using StackExchange.Redis; 10 | 11 | namespace Hangfire.Redis.Tests 12 | { 13 | [Collection("Sequential")] 14 | public class RedisWriteOnlyTransactionFacts 15 | { 16 | private readonly RedisStorage _storage; 17 | private readonly Mock _transaction; 18 | 19 | public RedisWriteOnlyTransactionFacts() 20 | { 21 | var options = new RedisStorageOptions() {Db = RedisUtils.GetDb()}; 22 | _storage = new RedisStorage(RedisUtils.GetHostAndPort(), options); 23 | 24 | _transaction = new Mock(); 25 | } 26 | 27 | [Fact] 28 | public void Ctor_ThrowsAnException_WhenStorageIsNull() 29 | { 30 | Assert.Throws("storage", 31 | () => new RedisWriteOnlyTransaction(null, _transaction.Object)); 32 | } 33 | 34 | [Fact] 35 | public void Ctor_ThrowsAnException_WhenTransactionIsNull() 36 | { 37 | Assert.Throws("transaction", 38 | () => new RedisWriteOnlyTransaction(_storage, null)); 39 | } 40 | 41 | [Fact, CleanRedis] 42 | public void ExpireJob_SetsExpirationDateForAllRelatedKeys() 43 | { 44 | UseConnection(redis => 45 | { 46 | // Arrange 47 | redis.StringSet("{hangfire}:job:my-job", "job"); 48 | redis.StringSet("{hangfire}:job:my-job:state", "state"); 49 | redis.StringSet("{hangfire}:job:my-job:history", "history"); 50 | 51 | // Act 52 | Commit(redis, x => x.ExpireJob("my-job", TimeSpan.FromDays(1))); 53 | 54 | // Assert 55 | var jobEntryTtl = redis.KeyTimeToLive("{hangfire}:job:my-job"); 56 | var stateEntryTtl = redis.KeyTimeToLive("{hangfire}:job:my-job:state"); 57 | var historyEntryTtl = redis.KeyTimeToLive("{hangfire}:job:my-job:state"); 58 | 59 | Assert.True(TimeSpan.FromHours(23) < jobEntryTtl && jobEntryTtl < TimeSpan.FromHours(25)); 60 | Assert.True(TimeSpan.FromHours(23) < stateEntryTtl && stateEntryTtl < TimeSpan.FromHours(25)); 61 | Assert.True(TimeSpan.FromHours(23) < historyEntryTtl && historyEntryTtl < TimeSpan.FromHours(25)); 62 | }); 63 | } 64 | 65 | [Fact, CleanRedis] 66 | public void SetJobState_ModifiesJobEntry() 67 | { 68 | UseConnection(redis => 69 | { 70 | // Arrange 71 | var state = new Mock(); 72 | state.Setup(x => x.SerializeData()).Returns(new Dictionary()); 73 | state.Setup(x => x.Name).Returns("my-state"); 74 | 75 | // Act 76 | Commit(redis, x => x.SetJobState("my-job", state.Object)); 77 | 78 | // Assert 79 | var hash = redis.HashGetAll("{hangfire}:job:my-job").ToStringDictionary(); 80 | Assert.Equal("my-state", hash["State"]); 81 | }); 82 | } 83 | 84 | [Fact, CleanRedis] 85 | public void SetJobState_RewritesStateEntry() 86 | { 87 | UseConnection(redis => 88 | { 89 | // Arrange 90 | redis.HashSet("{hangfire}:job:my-job:state", "OldName", "OldValue"); 91 | 92 | var state = new Mock(); 93 | state.Setup(x => x.SerializeData()).Returns( 94 | new Dictionary 95 | { 96 | {"Name", "Value"} 97 | }); 98 | state.Setup(x => x.Name).Returns("my-state"); 99 | state.Setup(x => x.Reason).Returns("my-reason"); 100 | 101 | // Act 102 | Commit(redis, x => x.SetJobState("my-job", state.Object)); 103 | 104 | // Assert 105 | var stateHash = redis.HashGetAll("{hangfire}:job:my-job:state").ToStringDictionary(); 106 | Assert.False(stateHash.ContainsKey("OldName")); 107 | Assert.Equal("my-state", stateHash["State"]); 108 | Assert.Equal("my-reason", stateHash["Reason"]); 109 | Assert.Equal("Value", stateHash["Name"]); 110 | }); 111 | } 112 | 113 | [Fact, CleanRedis] 114 | public void SetJobState_AppendsJobHistoryList() 115 | { 116 | UseConnection(redis => 117 | { 118 | // Arrange 119 | var state = new Mock(); 120 | state.Setup(x => x.Name).Returns("my-state"); 121 | state.Setup(x => x.SerializeData()).Returns(new Dictionary()); 122 | 123 | // Act 124 | Commit(redis, x => x.SetJobState("my-job", state.Object)); 125 | 126 | // Assert 127 | Assert.Equal(1, redis.ListLength("{hangfire}:job:my-job:history")); 128 | }); 129 | } 130 | 131 | [Fact, CleanRedis] 132 | public void PersistJob_RemovesExpirationDatesForAllRelatedKeys() 133 | { 134 | UseConnection(redis => 135 | { 136 | // Arrange 137 | redis.StringSet("{hangfire}:job:my-job", "job", TimeSpan.FromDays(1)); 138 | redis.StringSet("{hangfire}:job:my-job:state", "state", TimeSpan.FromDays(1)); 139 | redis.StringSet("{hangfire}:job:my-job:history", "history", TimeSpan.FromDays(1)); 140 | 141 | // Act 142 | Commit(redis, x => x.PersistJob("my-job")); 143 | 144 | // Assert 145 | Assert.Null(redis.KeyTimeToLive("{hangfire}:job:my-job")); 146 | Assert.Null(redis.KeyTimeToLive("{hangfire}:job:my-job:state")); 147 | Assert.Null(redis.KeyTimeToLive("{hangfire}:job:my-job:history")); 148 | }); 149 | } 150 | 151 | [Fact, CleanRedis] 152 | public void AddJobState_AddsJobHistoryEntry_AsJsonObject() 153 | { 154 | UseConnection(redis => 155 | { 156 | // Arrange 157 | var state = new Mock(); 158 | state.Setup(x => x.Name).Returns("my-state"); 159 | state.Setup(x => x.Reason).Returns("my-reason"); 160 | state.Setup(x => x.SerializeData()).Returns( 161 | new Dictionary {{"Name", "Value"}}); 162 | 163 | // Act 164 | Commit(redis, x => x.AddJobState("my-job", state.Object)); 165 | 166 | // Assert 167 | var serializedEntry = redis.ListGetByIndex("{hangfire}:job:my-job:history", 0); 168 | Assert.NotEqual(RedisValue.Null, serializedEntry); 169 | 170 | 171 | var entry = SerializationHelper.Deserialize>(serializedEntry); 172 | Assert.Equal("my-state", entry["State"]); 173 | Assert.Equal("my-reason", entry["Reason"]); 174 | Assert.Equal("Value", entry["Name"]); 175 | Assert.True(entry.ContainsKey("CreatedAt")); 176 | }); 177 | } 178 | 179 | [Fact, CleanRedis] 180 | public void AddToQueue_AddsSpecifiedJobToTheQueue() 181 | { 182 | UseConnection(redis => 183 | { 184 | Commit(redis, x => x.AddToQueue("critical", "my-job")); 185 | 186 | Assert.True(redis.SetContains("{hangfire}:queues", "critical")); 187 | Assert.Equal("my-job", (string) redis.ListGetByIndex("{hangfire}:queue:critical", 0)); 188 | }); 189 | } 190 | 191 | [Fact, CleanRedis] 192 | public void AddToQueue_PrependsListWithJob() 193 | { 194 | UseConnection(redis => 195 | { 196 | redis.ListLeftPush("{hangfire}:queue:critical", "another-job"); 197 | 198 | Commit(redis, x => x.AddToQueue("critical", "my-job")); 199 | 200 | Assert.Equal("my-job", (string) redis.ListGetByIndex("{hangfire}:queue:critical", 0)); 201 | }); 202 | } 203 | 204 | [Fact, CleanRedis] 205 | public void IncrementCounter_IncrementValueEntry() 206 | { 207 | UseConnection(redis => 208 | { 209 | redis.StringSet("{hangfire}:entry", "3"); 210 | 211 | Commit(redis, x => x.IncrementCounter("entry")); 212 | 213 | Assert.Equal("4", (string) redis.StringGet("{hangfire}:entry")); 214 | Assert.Null(redis.KeyTimeToLive("{hangfire}:entry")); 215 | }); 216 | } 217 | 218 | [Fact, CleanRedis] 219 | public void IncrementCounter_WithExpiry_IncrementsValueAndSetsExpirationDate() 220 | { 221 | UseConnection(redis => 222 | { 223 | redis.StringSet("{hangfire}:entry", "3"); 224 | 225 | Commit(redis, x => x.IncrementCounter("entry", TimeSpan.FromDays(1))); 226 | 227 | var entryTtl = redis.KeyTimeToLive("{hangfire}:entry").Value; 228 | Assert.Equal("4", (string) redis.StringGet("{hangfire}:entry")); 229 | Assert.True(TimeSpan.FromHours(23) < entryTtl && entryTtl < TimeSpan.FromHours(25)); 230 | }); 231 | } 232 | 233 | [Fact, CleanRedis] 234 | public void DecrementCounter_DecrementsTheValueEntry() 235 | { 236 | UseConnection(redis => 237 | { 238 | redis.StringSet("{hangfire}:entry", "3"); 239 | 240 | Commit(redis, x => x.DecrementCounter("entry")); 241 | 242 | Assert.Equal("2", (string) redis.StringGet("{hangfire}:entry")); 243 | Assert.Null(redis.KeyTimeToLive("{hangfire}:entry")); 244 | }); 245 | } 246 | 247 | [Fact, CleanRedis] 248 | public void DecrementCounter_WithExpiry_DecrementsTheValueAndSetsExpirationDate() 249 | { 250 | UseConnection(redis => 251 | { 252 | redis.StringSet("{hangfire}:entry", "3"); 253 | Commit(redis, x => x.DecrementCounter("entry", TimeSpan.FromDays(1))); 254 | var b = redis.KeyTimeToLive("{hangfire}:entry"); 255 | var entryTtl = redis.KeyTimeToLive("{hangfire}:entry").Value; 256 | Assert.Equal("2", (string) redis.StringGet("{hangfire}:entry")); 257 | Assert.True(TimeSpan.FromHours(23) < entryTtl && entryTtl < TimeSpan.FromHours(25)); 258 | }); 259 | } 260 | 261 | [Fact, CleanRedis] 262 | public void AddToSet_AddsItemToSortedSet() 263 | { 264 | UseConnection(redis => 265 | { 266 | Commit(redis, x => x.AddToSet("my-set", "my-value")); 267 | 268 | Assert.True(redis.SortedSetRank("{hangfire}:my-set", "my-value").HasValue); 269 | }); 270 | } 271 | 272 | [Fact, CleanRedis] 273 | public void AddToSet_WithScore_AddsItemToSortedSetWithScore() 274 | { 275 | UseConnection(redis => 276 | { 277 | Commit(redis, x => x.AddToSet("my-set", "my-value", 3.2)); 278 | 279 | Assert.True(redis.SortedSetRank("{hangfire}:my-set", "my-value").HasValue); 280 | Assert.Equal(3.2, redis.SortedSetScore("{hangfire}:my-set", "my-value").Value, 3); 281 | }); 282 | } 283 | 284 | [Fact, CleanRedis] 285 | public void RemoveFromSet_RemoveSpecifiedItemFromSortedSet() 286 | { 287 | UseConnection(redis => 288 | { 289 | redis.SortedSetAdd("{hangfire}:my-set", "my-value", 0); 290 | 291 | Commit(redis, x => x.RemoveFromSet("my-set", "my-value")); 292 | 293 | Assert.False(redis.SortedSetRank("{hangfire}:my-set", "my-value").HasValue); 294 | }); 295 | } 296 | 297 | [Fact, CleanRedis] 298 | public void InsertToList_PrependsListWithSpecifiedValue() 299 | { 300 | UseConnection(redis => 301 | { 302 | redis.ListRightPush("{hangfire}:list", "value"); 303 | 304 | Commit(redis, x => x.InsertToList("list", "new-value")); 305 | Assert.Equal("new-value", (string) redis.ListGetByIndex("{hangfire}:list", 0)); 306 | }); 307 | } 308 | 309 | [Fact, CleanRedis] 310 | public void RemoveFromList_RemovesAllGivenValuesFromList() 311 | { 312 | UseConnection(redis => 313 | { 314 | redis.ListRightPush("{hangfire}:list", "value"); 315 | redis.ListRightPush("{hangfire}:list", "another-value"); 316 | redis.ListRightPush("{hangfire}:list", "value"); 317 | 318 | Commit(redis, x => x.RemoveFromList("list", "value")); 319 | 320 | Assert.Equal(1, redis.ListLength("{hangfire}:list")); 321 | Assert.Equal("another-value", (string) redis.ListGetByIndex("{hangfire}:list", 0)); 322 | }); 323 | } 324 | 325 | [Fact, CleanRedis] 326 | public void TrimList_TrimsListToASpecifiedRange() 327 | { 328 | UseConnection(redis => 329 | { 330 | redis.ListRightPush("{hangfire}:list", "1"); 331 | redis.ListRightPush("{hangfire}:list", "2"); 332 | redis.ListRightPush("{hangfire}:list", "3"); 333 | redis.ListRightPush("{hangfire}:list", "4"); 334 | 335 | Commit(redis, x => x.TrimList("list", 1, 2)); 336 | 337 | Assert.Equal(2, redis.ListLength("{hangfire}:list")); 338 | Assert.Equal("2", (string) redis.ListGetByIndex("{hangfire}:list", 0)); 339 | Assert.Equal("3", (string) redis.ListGetByIndex("{hangfire}:list", 1)); 340 | }); 341 | } 342 | 343 | [Fact, CleanRedis] 344 | public void SetRangeInHash_ThrowsAnException_WhenKeyIsNull() 345 | { 346 | UseConnection(redis => 347 | { 348 | Assert.Throws("key", 349 | () => Commit(redis, x => x.SetRangeInHash(null, new Dictionary()))); 350 | }); 351 | } 352 | 353 | [Fact, CleanRedis] 354 | public void SetRangeInHash_ThrowsAnException_WhenKeyValuePairsArgumentIsNull() 355 | { 356 | UseConnection(redis => 357 | { 358 | Assert.Throws("keyValuePairs", 359 | () => Commit(redis, x => x.SetRangeInHash("some-hash", null))); 360 | }); 361 | } 362 | 363 | [Fact, CleanRedis] 364 | public void SetRangeInHash_SetsAllGivenKeyPairs() 365 | { 366 | UseConnection(redis => 367 | { 368 | Commit(redis, x => x.SetRangeInHash("some-hash", new Dictionary 369 | { 370 | {"Key1", "Value1"}, 371 | {"Key2", "Value2"} 372 | })); 373 | 374 | var hash = redis.HashGetAll("{hangfire}:some-hash").ToStringDictionary(); 375 | Assert.Equal("Value1", hash["Key1"]); 376 | Assert.Equal("Value2", hash["Key2"]); 377 | }); 378 | } 379 | 380 | [Fact, CleanRedis] 381 | public void RemoveHash_ThrowsAnException_WhenKeyIsNull() 382 | { 383 | UseConnection(redis => 384 | { 385 | Assert.Throws( 386 | () => Commit(redis, x => x.RemoveHash(null))); 387 | }); 388 | } 389 | 390 | [Fact, CleanRedis] 391 | public void RemoveHash_RemovesTheCorrespondingEntry() 392 | { 393 | UseConnection(redis => 394 | { 395 | redis.HashSet("{hangfire}:some-hash", "key", "value"); 396 | 397 | Commit(redis, x => x.RemoveHash("some-hash")); 398 | 399 | var hash = redis.HashGetAll("{hangfire}:some-hash"); 400 | Assert.Empty(hash); 401 | }); 402 | } 403 | 404 | private void Commit(IDatabase redis, Action action) 405 | { 406 | using (var transaction = new RedisWriteOnlyTransaction(_storage, redis.CreateTransaction())) 407 | { 408 | action(transaction); 409 | transaction.Commit(); 410 | } 411 | } 412 | 413 | private static void UseConnection(Action action) 414 | { 415 | var redis = RedisUtils.CreateClient(); 416 | action(redis); 417 | } 418 | } 419 | } -------------------------------------------------------------------------------- /Hangfire.Redis.StackExchange/RedisConnection.cs: -------------------------------------------------------------------------------- 1 | // Copyright � 2013-2015 Sergey Odinokov, Marco Casamento 2 | // This software is based on https://github.com/HangfireIO/Hangfire.Redis 3 | 4 | // Hangfire.Redis.StackExchange is free software: you can redistribute it and/or modify 5 | // it under the terms of the GNU Lesser General Public License as 6 | // published by the Free Software Foundation, either version 3 7 | // of the License, or any later version. 8 | // 9 | // Hangfire.Redis.StackExchange is distributed in the hope that it will be useful, 10 | // but WITHOUT ANY WARRANTY; without even the implied warranty of 11 | // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12 | // GNU Lesser General Public License for more details. 13 | // 14 | // You should have received a copy of the GNU Lesser General Public 15 | // License along with Hangfire.Redis.StackExchange. If not, see . 16 | 17 | using Hangfire.Annotations; 18 | using Hangfire.Common; 19 | using Hangfire.Server; 20 | using Hangfire.Storage; 21 | using StackExchange.Redis; 22 | using System; 23 | using System.Collections.Concurrent; 24 | using System.Collections.Generic; 25 | using System.Globalization; 26 | using System.Linq; 27 | using System.Threading; 28 | using System.Threading.Tasks; 29 | 30 | namespace Hangfire.Redis.StackExchange 31 | { 32 | internal class RedisConnection : JobStorageConnection 33 | { 34 | private readonly RedisStorage _storage; 35 | private readonly RedisSubscription _subscription; 36 | private readonly TimeSpan _fetchTimeout = TimeSpan.FromMinutes(3); 37 | private readonly IServer redisServer; 38 | public RedisConnection( 39 | [NotNull] RedisStorage storage, 40 | [NotNull] IServer redisServer, 41 | [NotNull] IDatabase redis, 42 | [NotNull] RedisSubscription subscription, 43 | TimeSpan fetchTimeout) 44 | { 45 | _storage = storage ?? throw new ArgumentNullException(nameof(storage)); 46 | this.redisServer = redisServer; 47 | _subscription = subscription ?? throw new ArgumentNullException(nameof(subscription)); 48 | _fetchTimeout = fetchTimeout; 49 | 50 | Redis = redis ?? throw new ArgumentNullException(nameof(redis)); 51 | } 52 | 53 | public IDatabase Redis { get; } 54 | 55 | public override IDisposable AcquireDistributedLock([NotNull] string resource, TimeSpan timeout) 56 | { 57 | return RedisLock.Acquire(Redis, _storage.GetRedisKey(resource), timeout); 58 | } 59 | 60 | public override void AnnounceServer([NotNull] string serverId, [NotNull] ServerContext context) 61 | { 62 | if (serverId == null) throw new ArgumentNullException(nameof(serverId)); 63 | if (context == null) throw new ArgumentNullException(nameof(context)); 64 | 65 | if (_storage.UseTransactions) 66 | { 67 | var transaction = Redis.CreateTransaction(); 68 | 69 | _ = transaction.SetAddAsync(_storage.GetRedisKey("servers"), serverId); 70 | 71 | _ = transaction.HashSetAsync( 72 | _storage.GetRedisKey($"server:{serverId}"), 73 | new Dictionary 74 | { 75 | { "WorkerCount", context.WorkerCount.ToString(CultureInfo.InvariantCulture) }, 76 | { "StartedAt", JobHelper.SerializeDateTime(DateTime.UtcNow) }, 77 | }.ToHashEntries()); 78 | 79 | if (context.Queues.Length > 0) 80 | { 81 | _ = transaction.ListRightPushAsync( 82 | _storage.GetRedisKey($"server:{serverId}:queues"), 83 | context.Queues.ToRedisValues()); 84 | } 85 | 86 | _ = transaction.Execute(); 87 | } 88 | else 89 | { 90 | var tasks = new Task[3]; 91 | tasks[0] = Redis.SetAddAsync(_storage.GetRedisKey("servers"), serverId); 92 | 93 | tasks[1] = Redis.HashSetAsync( 94 | _storage.GetRedisKey($"server:{serverId}"), 95 | new Dictionary 96 | { 97 | { "WorkerCount", context.WorkerCount.ToString(CultureInfo.InvariantCulture) }, 98 | { "StartedAt", JobHelper.SerializeDateTime(DateTime.UtcNow) }, 99 | }.ToHashEntries()); 100 | 101 | if (context.Queues.Length > 0) 102 | { 103 | tasks[2] = Redis.ListRightPushAsync( 104 | _storage.GetRedisKey($"server:{serverId}:queues"), 105 | context.Queues.ToRedisValues()); 106 | } 107 | else 108 | { 109 | tasks[2] = Task.CompletedTask;; 110 | } 111 | 112 | Task.WaitAll(tasks); 113 | } 114 | } 115 | 116 | public override DateTime GetUtcDateTime() 117 | { 118 | //the returned time is the time on the first server of the cluster 119 | return redisServer.Time(); 120 | } 121 | 122 | public override long GetSetCount([NotNull] IEnumerable keys, int limit) 123 | { 124 | Task[] tasks = new Task[keys.Count()]; 125 | int i = 0; 126 | IBatch batch = Redis.CreateBatch(); 127 | ConcurrentDictionary results = new ConcurrentDictionary(); 128 | foreach (string key in keys) 129 | { 130 | tasks[i] = batch.SortedSetLengthAsync(_storage.GetRedisKey(key), max: limit) 131 | .ContinueWith((Task x) => results.TryAdd(_storage.GetRedisKey(key), x.Result)); 132 | } 133 | batch.Execute(); 134 | Task.WaitAll(tasks); 135 | return results.Sum(x => x.Value); 136 | } 137 | 138 | public override bool GetSetContains([NotNull] string key, [NotNull] string value) 139 | { 140 | var sortedSetEntries = Redis.SortedSetScan(_storage.GetRedisKey(key), value); 141 | return sortedSetEntries.Any(); 142 | } 143 | 144 | public override string CreateExpiredJob( 145 | [NotNull] Job job, 146 | [NotNull] IDictionary parameters, 147 | DateTime createdAt, 148 | TimeSpan expireIn) 149 | { 150 | if (job == null) throw new ArgumentNullException(nameof(job)); 151 | if (parameters == null) throw new ArgumentNullException(nameof(parameters)); 152 | 153 | var jobId = Guid.NewGuid().ToString(); 154 | 155 | var invocationData = InvocationData.SerializeJob(job); 156 | 157 | // Do not modify the original parameters. 158 | var storedParameters = new Dictionary(parameters) 159 | { 160 | { "Type", invocationData.Type }, 161 | { "Method", invocationData.Method }, 162 | { "ParameterTypes", invocationData.ParameterTypes }, 163 | { "Arguments", invocationData.Arguments }, 164 | { "CreatedAt", JobHelper.SerializeDateTime(createdAt) } 165 | }; 166 | 167 | if (invocationData.Queue != null) 168 | { 169 | storedParameters.Add("Queue", invocationData.Queue); 170 | } 171 | 172 | if (_storage.UseTransactions) 173 | { 174 | var transaction = Redis.CreateTransaction(); 175 | 176 | _ = transaction.HashSetAsync(_storage.GetRedisKey($"job:{jobId}"), storedParameters.ToHashEntries()); 177 | _ = transaction.KeyExpireAsync(_storage.GetRedisKey($"job:{jobId}"), expireIn); 178 | 179 | if (!transaction.Execute()) 180 | throw new HangfireRedisTransactionException("Transaction Execution failure"); 181 | } 182 | else 183 | { 184 | var tasks = new Task[2]; 185 | tasks[0] = Redis.HashSetAsync(_storage.GetRedisKey($"job:{jobId}"), storedParameters.ToHashEntries()); 186 | tasks[1] = Redis.KeyExpireAsync(_storage.GetRedisKey($"job:{jobId}"), expireIn); 187 | Task.WaitAll(tasks); 188 | } 189 | 190 | return jobId; 191 | } 192 | 193 | public override IWriteOnlyTransaction CreateWriteTransaction() 194 | { 195 | if (_storage.UseTransactions) 196 | { 197 | return new RedisWriteOnlyTransaction(_storage, Redis.CreateTransaction()); 198 | } 199 | return new RedisWriteDirectlyToDatabase(_storage, Redis); 200 | } 201 | 202 | public override void Dispose() 203 | { 204 | // nothing to dispose 205 | } 206 | 207 | public override IFetchedJob FetchNextJob([NotNull] string[] queues, CancellationToken cancellationToken) 208 | { 209 | if (queues == null) throw new ArgumentNullException(nameof(queues)); 210 | 211 | string jobId = null; 212 | string queueName = null; 213 | do 214 | { 215 | cancellationToken.ThrowIfCancellationRequested(); 216 | 217 | for (int i = 0; i < queues.Length; i++) 218 | { 219 | queueName = queues[i]; 220 | var queueKey = _storage.GetRedisKey($"queue:{queueName}"); 221 | var fetchedKey = _storage.GetRedisKey($"queue:{queueName}:dequeued"); 222 | jobId = Redis.ListRightPopLeftPush(queueKey, fetchedKey); 223 | if (jobId != null) break; 224 | } 225 | 226 | if (jobId == null) 227 | { 228 | _subscription.WaitForJob(_fetchTimeout, cancellationToken); 229 | } 230 | } 231 | while (jobId == null); 232 | 233 | // The job was fetched by the server. To provide reliability, 234 | // we should ensure, that the job will be performed and acquired 235 | // resources will be disposed even if the server will crash 236 | // while executing one of the subsequent lines of code. 237 | 238 | // The job's processing is splitted into a couple of checkpoints. 239 | // Each checkpoint occurs after successful update of the 240 | // job information in the storage. And each checkpoint describes 241 | // the way to perform the job when the server was crashed after 242 | // reaching it. 243 | 244 | // Checkpoint #1-1. The job was fetched into the fetched list, 245 | // that is being inspected by the FetchedJobsWatcher instance. 246 | // Job's has the implicit 'Fetched' state. 247 | 248 | //strip out microseconds from fetchTime as the serialized version will anyway truncate them 249 | var fetchTime = new DateTime(DateTime.UtcNow.Ticks / 10000 * 10000); 250 | 251 | _ = Redis.HashSet( 252 | _storage.GetRedisKey($"job:{jobId}"), 253 | "Fetched", 254 | JobHelper.SerializeDateTime(fetchTime)); 255 | 256 | // Checkpoint #2. The job is in the implicit 'Fetched' state now. 257 | // This state stores information about fetched time. The job will 258 | // be re-queued when the JobTimeout will be expired. 259 | 260 | return new RedisFetchedJob(_storage, Redis, jobId, queueName, fetchTime); 261 | } 262 | 263 | public override Dictionary GetAllEntriesFromHash([NotNull] string key) 264 | { 265 | var result = Redis.HashGetAll(_storage.GetRedisKey(key)).ToStringDictionary(); 266 | 267 | return result.Count != 0 ? result : null; 268 | } 269 | 270 | public override List GetAllItemsFromList([NotNull] string key) 271 | { 272 | return Redis.ListRange(_storage.GetRedisKey(key)).ToStringArray().ToList(); 273 | } 274 | 275 | public override HashSet GetAllItemsFromSet([NotNull] string key) 276 | { 277 | HashSet result = new HashSet(); 278 | foreach (var item in Redis.SortedSetScan(_storage.GetRedisKey(key))) 279 | { 280 | _ = result.Add(item.Element); 281 | } 282 | 283 | return result; 284 | } 285 | 286 | public override long GetCounter([NotNull] string key) 287 | { 288 | return Convert.ToInt64(Redis.StringGet(_storage.GetRedisKey(key))); 289 | } 290 | 291 | public override string GetFirstByLowestScoreFromSet([NotNull] string key, double fromScore, double toScore) 292 | { 293 | return Redis.SortedSetRangeByScore(_storage.GetRedisKey(key), fromScore, toScore, skip: 0, take: 1) 294 | .FirstOrDefault(); 295 | } 296 | 297 | public override List GetFirstByLowestScoreFromSet(string key, double fromScore, double toScore, int count) 298 | { 299 | return Redis.SortedSetRangeByScore(_storage.GetRedisKey(key), fromScore, toScore, skip: 0, take: count) 300 | .Where(x => x.HasValue) 301 | .Select(x => x.ToString()) 302 | .ToList(); 303 | } 304 | 305 | public override long GetHashCount([NotNull] string key) 306 | { 307 | return Redis.HashLength(_storage.GetRedisKey(key)); 308 | } 309 | 310 | public override TimeSpan GetHashTtl([NotNull] string key) 311 | { 312 | return Redis.KeyTimeToLive(_storage.GetRedisKey(key)) ?? TimeSpan.Zero; 313 | } 314 | 315 | public override JobData GetJobData([NotNull] string jobId) 316 | { 317 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 318 | 319 | var storedData = Redis.HashGetAll(_storage.GetRedisKey($"job:{jobId}")); 320 | if (storedData.Length == 0) return null; 321 | 322 | string queue = storedData.FirstOrDefault(x => x.Name == "Queue").Value; 323 | string type = storedData.FirstOrDefault(x => x.Name == "Type").Value; 324 | string method = storedData.FirstOrDefault(x => x.Name == "Method").Value; 325 | string parameterTypes = storedData.FirstOrDefault(x => x.Name == "ParameterTypes").Value; 326 | string arguments = storedData.FirstOrDefault(x => x.Name == "Arguments").Value; 327 | string createdAt = storedData.FirstOrDefault(x => x.Name == "CreatedAt").Value; 328 | 329 | Job job = null; 330 | JobLoadException loadException = null; 331 | 332 | var invocationData = new InvocationData(type, method, parameterTypes, arguments, queue); 333 | 334 | try 335 | { 336 | job = invocationData.DeserializeJob(); 337 | } 338 | catch (JobLoadException ex) 339 | { 340 | loadException = ex; 341 | } 342 | 343 | return new JobData 344 | { 345 | Job = job, 346 | State = storedData.FirstOrDefault(x => x.Name == "State").Value, 347 | CreatedAt = JobHelper.DeserializeNullableDateTime(createdAt) ?? DateTime.MinValue, 348 | LoadException = loadException 349 | }; 350 | } 351 | 352 | public override string GetJobParameter([NotNull] string jobId, [NotNull] string name) 353 | { 354 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 355 | if (name == null) throw new ArgumentNullException(nameof(name)); 356 | 357 | return Redis.HashGet(_storage.GetRedisKey($"job:{jobId}"), name); 358 | } 359 | 360 | public override long GetListCount([NotNull] string key) 361 | { 362 | return Redis.ListLength(_storage.GetRedisKey(key)); 363 | } 364 | 365 | public override TimeSpan GetListTtl([NotNull] string key) 366 | { 367 | return Redis.KeyTimeToLive(_storage.GetRedisKey(key)) ?? TimeSpan.Zero; 368 | } 369 | 370 | public override List GetRangeFromList([NotNull] string key, int startingFrom, int endingAt) 371 | { 372 | return Redis.ListRange(_storage.GetRedisKey(key), startingFrom, endingAt).ToStringArray().ToList(); 373 | } 374 | 375 | public override List GetRangeFromSet([NotNull] string key, int startingFrom, int endingAt) 376 | { 377 | return Redis.SortedSetRangeByRank(_storage.GetRedisKey(key), startingFrom, endingAt).ToStringArray().ToList(); 378 | } 379 | 380 | public override long GetSetCount([NotNull] string key) 381 | { 382 | return Redis.SortedSetLength(_storage.GetRedisKey(key)); 383 | } 384 | 385 | public override TimeSpan GetSetTtl([NotNull] string key) 386 | { 387 | return Redis.KeyTimeToLive(_storage.GetRedisKey(key)) ?? TimeSpan.Zero; 388 | } 389 | 390 | public override StateData GetStateData([NotNull] string jobId) 391 | { 392 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 393 | 394 | var entries = Redis.HashGetAll(_storage.GetRedisKey($"job:{jobId}:state")); 395 | if (entries.Length == 0) return null; 396 | 397 | var stateData = entries.ToStringDictionary(); 398 | 399 | _ = stateData.Remove("State"); 400 | _ = stateData.Remove("Reason"); 401 | 402 | return new StateData 403 | { 404 | Name = entries.First(x => x.Name == "State").Value, 405 | Reason = entries.FirstOrDefault(x => x.Name == "Reason").Value, 406 | Data = stateData 407 | }; 408 | } 409 | 410 | public override string GetValueFromHash([NotNull] string key, [NotNull] string name) 411 | { 412 | if (name == null) throw new ArgumentNullException(nameof(name)); 413 | 414 | return Redis.HashGet(_storage.GetRedisKey(key), name); 415 | } 416 | 417 | public override void Heartbeat([NotNull] string serverId) 418 | { 419 | if (serverId == null) throw new ArgumentNullException(nameof(serverId)); 420 | 421 | _ = Redis.HashSet( 422 | _storage.GetRedisKey($"server:{serverId}"), 423 | "Heartbeat", 424 | JobHelper.SerializeDateTime(DateTime.UtcNow)); 425 | } 426 | 427 | public override void RemoveServer([NotNull] string serverId) 428 | { 429 | if (serverId == null) throw new ArgumentNullException(nameof(serverId)); 430 | 431 | if (_storage.UseTransactions) 432 | { 433 | var transaction = Redis.CreateTransaction(); 434 | 435 | _ = transaction.SetRemoveAsync(_storage.GetRedisKey("servers"), serverId); 436 | 437 | _ = transaction.KeyDeleteAsync( 438 | new RedisKey[] 439 | { 440 | _storage.GetRedisKey($"server:{serverId}"), 441 | _storage.GetRedisKey($"server:{serverId}:queues") 442 | }); 443 | 444 | _ = transaction.Execute(); 445 | } 446 | else 447 | { 448 | var tasks = new Task[2]; 449 | tasks[0] = Redis.SetRemoveAsync(_storage.GetRedisKey("servers"), serverId); 450 | tasks[1] = Redis.KeyDeleteAsync( 451 | new RedisKey[] 452 | { 453 | _storage.GetRedisKey($"server:{serverId}"), 454 | _storage.GetRedisKey($"server:{serverId}:queues") 455 | }); 456 | Task.WaitAll(tasks); 457 | } 458 | } 459 | 460 | public override int RemoveTimedOutServers(TimeSpan timeOut) 461 | { 462 | var serverNames = Redis.SetMembers(_storage.GetRedisKey("servers")); 463 | var heartbeats = new Dictionary>(); 464 | 465 | var utcNow = DateTime.UtcNow; 466 | 467 | foreach (var serverName in serverNames) 468 | { 469 | var srv = Redis.HashGet(_storage.GetRedisKey($"server:{serverName}"), new RedisValue[] { "StartedAt", "Heartbeat" }); 470 | heartbeats.Add(serverName, 471 | new Tuple( 472 | JobHelper.DeserializeDateTime(srv[0]), 473 | JobHelper.DeserializeNullableDateTime(srv[1]))); 474 | } 475 | 476 | var removedServerCount = 0; 477 | foreach (var heartbeat in heartbeats) 478 | { 479 | var maxTime = new DateTime( 480 | Math.Max(heartbeat.Value.Item1.Ticks, (heartbeat.Value.Item2 ?? DateTime.MinValue).Ticks)); 481 | 482 | if (utcNow > maxTime.Add(timeOut)) 483 | { 484 | RemoveServer(heartbeat.Key); 485 | removedServerCount++; 486 | } 487 | } 488 | 489 | return removedServerCount; 490 | } 491 | 492 | public override void SetJobParameter([NotNull] string jobId, [NotNull] string name, string value) 493 | { 494 | if (jobId == null) throw new ArgumentNullException(nameof(jobId)); 495 | if (name == null) throw new ArgumentNullException(nameof(name)); 496 | 497 | _ = Redis.HashSet(_storage.GetRedisKey($"job:{jobId}"), name, value); 498 | } 499 | 500 | public override void SetRangeInHash([NotNull] string key, [NotNull] IEnumerable> keyValuePairs) 501 | { 502 | if (keyValuePairs == null) throw new ArgumentNullException(nameof(keyValuePairs)); 503 | 504 | Redis.HashSet(_storage.GetRedisKey(key), keyValuePairs.ToHashEntries()); 505 | } 506 | } 507 | } 508 | --------------------------------------------------------------------------------