diff --git a/src/main/Hangfire.Storage.SQLite/Assembly.cs b/src/main/Hangfire.Storage.SQLite/Assembly.cs new file mode 100644 index 0000000..21237cb --- /dev/null +++ b/src/main/Hangfire.Storage.SQLite/Assembly.cs @@ -0,0 +1,3 @@ +using System.Runtime.CompilerServices; + +[assembly: InternalsVisibleTo("Hangfire.Storage.SQLite.Test")] \ No newline at end of file diff --git a/src/main/Hangfire.Storage.SQLite/HangfireDbContext.cs b/src/main/Hangfire.Storage.SQLite/HangfireDbContext.cs index 539b011..5e290a3 100644 --- a/src/main/Hangfire.Storage.SQLite/HangfireDbContext.cs +++ b/src/main/Hangfire.Storage.SQLite/HangfireDbContext.cs @@ -50,41 +50,45 @@ public void Init(SQLiteStorageOptions storageOptions) { StorageOptions = storageOptions; - TryFewTimesDueToConcurrency(() => InitializePragmas(storageOptions)); - TryFewTimesDueToConcurrency(() => Database.CreateTable()); - TryFewTimesDueToConcurrency(() => Database.CreateTable()); - TryFewTimesDueToConcurrency(() => Database.CreateTable()); - TryFewTimesDueToConcurrency(() => Database.CreateTable()); - TryFewTimesDueToConcurrency(() => Database.CreateTable()); - TryFewTimesDueToConcurrency(() => Database.CreateTable()); - TryFewTimesDueToConcurrency(() => Database.CreateTable()); - TryFewTimesDueToConcurrency(() => Database.CreateTable()); - TryFewTimesDueToConcurrency(() => Database.CreateTable()); - TryFewTimesDueToConcurrency(() => Database.CreateTable()); - TryFewTimesDueToConcurrency(() => Database.CreateTable()); - - void TryFewTimesDueToConcurrency(Action action, int times = 10) + TryFewTimesDueToConcurrency(state => state.Item1.InitializePragmas(state.storageOptions), + (this, storageOptions)); + } + + public void Migrate() + { + TryFewTimesDueToConcurrency(db => db.CreateTable(), Database); + TryFewTimesDueToConcurrency(db => db.CreateTable(), Database); + TryFewTimesDueToConcurrency(db => db.CreateTable(), Database); + TryFewTimesDueToConcurrency(db => db.CreateTable(), Database); + TryFewTimesDueToConcurrency(db => db.CreateTable(), Database); + TryFewTimesDueToConcurrency(db => db.CreateTable(), Database); + TryFewTimesDueToConcurrency(db => db.CreateTable(), Database); + TryFewTimesDueToConcurrency(db => db.CreateTable(), Database); + TryFewTimesDueToConcurrency(db => db.CreateTable(), Database); + TryFewTimesDueToConcurrency(db => db.CreateTable(), Database); + TryFewTimesDueToConcurrency(db => db.CreateTable(), Database); + } + + static void TryFewTimesDueToConcurrency(Action action, TState state, int times = 10) + { + var current = 0; + while (current < times) { - var current = 0; - while (current < times) + try + { + action(state); + return; + } + catch (SQLiteException e) when (e.Result == SQLite3.Result.Locked) { - try - { - action(); - return; - } - catch (SQLiteException e) when (e.Result == SQLite3.Result.Locked) - { - // This can happen if too many connections are opened - // at the same time, trying to create tables - Thread.Sleep(10); - } - current++; + // This can happen if too many connections are opened + // at the same time, trying to create tables + Thread.Sleep(10); } + current++; } } - private void InitializePragmas(SQLiteStorageOptions storageOptions) { try diff --git a/src/main/Hangfire.Storage.SQLite/HangfireSQLiteConnection.cs b/src/main/Hangfire.Storage.SQLite/HangfireSQLiteConnection.cs index 48668f3..bf0dbdb 100644 --- a/src/main/Hangfire.Storage.SQLite/HangfireSQLiteConnection.cs +++ b/src/main/Hangfire.Storage.SQLite/HangfireSQLiteConnection.cs @@ -231,6 +231,28 @@ public override long GetSetCount(string key) .SetRepository .Count(_ => _.Key == key); } + + public override long GetSetCount(IEnumerable keys, int limit) + { + if (keys == null) + { + throw new ArgumentNullException(nameof(keys)); + } + + var count = DbContext + .SetRepository + .Where(_ => keys.Contains(_.Key)) + .Take(limit) + .Count(); + return Math.Min(count, limit); + } + + public override bool GetSetContains(string key, string value) + { + return DbContext + .SetRepository + .Any(x => x.Key == key && x.Value == value); + } public override string GetFirstByLowestScoreFromSet(string key, double fromScore, double toScore) { @@ -257,6 +279,32 @@ public override string GetFirstByLowestScoreFromSet(string key, double fromScore .FirstOrDefault(); } + public override List GetFirstByLowestScoreFromSet(string key, double fromScore, double toScore, int count) + { + if (key == null) + { + throw new ArgumentNullException(nameof(key)); + } + + if (toScore < fromScore) + { + throw new ArgumentException("The 'toScore' value must be higher or equal to the 'fromScore' value."); + } + + var fromScoreDec = fromScore.ToInt64(); + var toScoreDec = toScore.ToInt64(); + + return DbContext + .SetRepository + .Where(_ => _.Key == key && + _.Score >= fromScoreDec && + _.Score <= toScoreDec) + .OrderBy(_ => _.Score) + .Select(_ => _.Value) + .Take(count) + .ToList(); + } + public override JobData GetJobData(string jobId) { if (jobId == null) @@ -434,7 +482,7 @@ public override void SetJobParameter(string id, string name, string value) public override void SetRangeInHash(string key, IEnumerable> keyValuePairs) { - using (var transaction = new SQLiteWriteOnlyTransaction(DbContext, _queueProviders)) + using (var transaction = CreateWriteTransaction()) { transaction.SetRangeInHash(key, keyValuePairs); transaction.Commit(); @@ -561,6 +609,11 @@ public override TimeSpan GetListTtl(string key) return result != DateTime.MinValue ? result - DateTime.UtcNow : TimeSpan.FromSeconds(-1); } + public override DateTime GetUtcDateTime() + { + return DateTime.UtcNow; + } + public override List GetRangeFromList(string key, int startingFrom, int endingAt) { if (key == null) diff --git a/src/main/Hangfire.Storage.SQLite/PersistentJobQueueProviderCollection.cs b/src/main/Hangfire.Storage.SQLite/PersistentJobQueueProviderCollection.cs index 875980e..aa3b27a 100644 --- a/src/main/Hangfire.Storage.SQLite/PersistentJobQueueProviderCollection.cs +++ b/src/main/Hangfire.Storage.SQLite/PersistentJobQueueProviderCollection.cs @@ -52,8 +52,8 @@ public void Add(IPersistentJobQueueProvider provider, IEnumerable queues /// public IPersistentJobQueueProvider GetProvider(string queue) { - return _providersByQueue.ContainsKey(queue) - ? _providersByQueue[queue] + return _providersByQueue.TryGetValue(queue, out var value) + ? value : _defaultProvider; } diff --git a/src/main/Hangfire.Storage.SQLite/SQLiteDistributedLock.cs b/src/main/Hangfire.Storage.SQLite/SQLiteDistributedLock.cs index 6b78114..158f46c 100644 --- a/src/main/Hangfire.Storage.SQLite/SQLiteDistributedLock.cs +++ b/src/main/Hangfire.Storage.SQLite/SQLiteDistributedLock.cs @@ -83,6 +83,7 @@ public void Dispose() _completed = true; _heartbeatTimer?.Dispose(); + _heartbeatTimer = null; Release(); } @@ -184,7 +185,14 @@ private void StartHeartBeat() Logger.ErrorFormat("Unable to update heartbeat on the resource '{0}'. The resource is not locked or is locked by another owner.", _resource); // if we no longer have a lock, stop the heartbeat immediately - _heartbeatTimer?.Dispose(); + try + { + _heartbeatTimer?.Dispose(); + } + catch (ObjectDisposedException) + { + // well, already disposed? + } return; } } diff --git a/src/main/Hangfire.Storage.SQLite/SQLiteStorage.cs b/src/main/Hangfire.Storage.SQLite/SQLiteStorage.cs index 7f7adc3..084521c 100644 --- a/src/main/Hangfire.Storage.SQLite/SQLiteStorage.cs +++ b/src/main/Hangfire.Storage.SQLite/SQLiteStorage.cs @@ -15,21 +15,21 @@ public class SQLiteStorage : JobStorage, IDisposable private readonly SQLiteStorageOptions _storageOptions; - private readonly Dictionary _features = new Dictionary(StringComparer.OrdinalIgnoreCase) + private static readonly Dictionary _features = new Dictionary(StringComparer.OrdinalIgnoreCase) { - { "Storage.ExtendedApi", false }, - { "Job.Queue", true }, - { "Connection.GetUtcDateTime", false }, - { "Connection.BatchedGetFirstByLowestScoreFromSet", false }, - { "Connection.GetSetContains", true }, - { "Connection.GetSetCount.Limited", false }, - { "BatchedGetFirstByLowestScoreFromSet", false }, - { "Transaction.AcquireDistributedLock", true }, - { "Transaction.CreateJob", true }, - { "Transaction.SetJobParameter", true }, - { "TransactionalAcknowledge:InMemoryFetchedJob", false }, - { "Monitoring.DeletedStateGraphs", false }, - { "Monitoring.AwaitingJobs", false } + { JobStorageFeatures.ExtendedApi, true }, + { JobStorageFeatures.JobQueueProperty, true }, + { JobStorageFeatures.Connection.GetUtcDateTime, true }, + { JobStorageFeatures.Connection.BatchedGetFirstByLowest, true }, + { "BatchedGetFirstByLowestScoreFromSet", true }, // ^-- legacy name? + { JobStorageFeatures.Connection.GetSetContains, true }, + { JobStorageFeatures.Connection.LimitedGetSetCount, true }, + { JobStorageFeatures.Transaction.AcquireDistributedLock, true }, + { JobStorageFeatures.Transaction.CreateJob, false }, // NOTE: implement SQLiteWriteOnlyTransaction.CreateJob(...) + { JobStorageFeatures.Transaction.SetJobParameter, false }, // NOTE: implement SQLiteWriteOnlyTransaction.SetJobParameter(...) + { JobStorageFeatures.Transaction.RemoveFromQueue(typeof(SQLiteFetchedJob)), false }, // NOTE: implement SQLiteWriteOnlyTransaction.RemoveFromQueue(...) + { JobStorageFeatures.Monitoring.DeletedStateGraphs, false }, + { JobStorageFeatures.Monitoring.AwaitingJobs, false } }; private ConcurrentQueue _dbContextPool = new ConcurrentQueue(); @@ -77,6 +77,7 @@ public SQLiteStorage(SQLiteDbConnectionFactory dbConnectionFactory, SQLiteStorag using (var dbContext = CreateAndOpenConnection()) { + dbContext.Migrate(); _databasePath = dbContext.Database.DatabasePath; // Use this to initialize the database as soon as possible // in case of error, the user will immediately get an exception at startup diff --git a/src/main/Hangfire.Storage.SQLite/SQLiteWriteOnlyTransaction.cs b/src/main/Hangfire.Storage.SQLite/SQLiteWriteOnlyTransaction.cs index 65d3e19..45b2537 100644 --- a/src/main/Hangfire.Storage.SQLite/SQLiteWriteOnlyTransaction.cs +++ b/src/main/Hangfire.Storage.SQLite/SQLiteWriteOnlyTransaction.cs @@ -1,8 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Linq.Expressions; -using System.Text; +using Hangfire.Logging; using Hangfire.States; using Hangfire.Storage.SQLite.Entities; using Newtonsoft.Json; @@ -17,8 +16,10 @@ public class SQLiteWriteOnlyTransaction : JobStorageTransaction private readonly PersistentJobQueueProviderCollection _queueProviders; - private static object _lockObject = new object(); - + internal readonly List _acquiredLocks = new List(); + + private static readonly ILog Logger = LogProvider.For(); + /// /// /// @@ -36,6 +37,15 @@ private void QueueCommand(Action action) _commandQueue.Enqueue(action); } + public override void AcquireDistributedLock(string resource, TimeSpan timeout) + { + var acquiredLock = SQLiteDistributedLock.Acquire(resource, timeout, _dbContext, _dbContext.StorageOptions); + lock (_acquiredLocks) + { + _acquiredLocks.Add(acquiredLock); + } + } + public override void AddJobState(string jobId, IState state) { QueueCommand(_ => @@ -113,16 +123,44 @@ public override void AddToSet(string key, string value, double score) public override void Commit() { - Retry.Twice((attempts) => { - - lock (_lockObject) - { + try + { + Retry.Twice((attempts) => { _commandQueue.ToList().ForEach(_ => { _.Invoke(_dbContext); }); + }); + } + finally + { + ReleaseAcquiredLocks(); + } + } + + private void ReleaseAcquiredLocks() + { + lock (_acquiredLocks) + { + foreach (var acquiredLock in _acquiredLocks) + { + try + { + acquiredLock.Dispose(); + } + catch (Exception ex) + { + Logger.WarnException("Failed to release a distributed lock", ex); + } } - }); + _acquiredLocks.Clear(); + } + } + + public override void Dispose() + { + ReleaseAcquiredLocks(); + base.Dispose(); } /// diff --git a/src/test/Hangfire.Storage.SQLite.Test/CountersAggregatorFacts.cs b/src/test/Hangfire.Storage.SQLite.Test/CountersAggregatorFacts.cs index e7a5d51..0f6ad4a 100644 --- a/src/test/Hangfire.Storage.SQLite.Test/CountersAggregatorFacts.cs +++ b/src/test/Hangfire.Storage.SQLite.Test/CountersAggregatorFacts.cs @@ -1,18 +1,16 @@ using Hangfire.Storage.SQLite.Entities; -using Hangfire.Storage.SQLite.Test.Utils; using System; using System.Threading; using Xunit; namespace Hangfire.Storage.SQLite.Test { - public class CountersAggregatorFacts + public class CountersAggregatorFacts : SqliteInMemoryTestBase { [Fact] public void CountersAggregatorExecutesProperly() { - var storage = ConnectionUtils.CreateStorage(); - using (var connection = (HangfireSQLiteConnection)storage.GetConnection()) + using (var connection = (HangfireSQLiteConnection)Storage.GetConnection()) { // Arrange connection.DbContext.Database.Insert(new Counter @@ -23,7 +21,7 @@ public void CountersAggregatorExecutesProperly() ExpireAt = DateTime.UtcNow.AddHours(1) }); - var aggregator = new CountersAggregator(storage, TimeSpan.Zero); + var aggregator = new CountersAggregator(Storage, TimeSpan.Zero); var cts = new CancellationTokenSource(); cts.Cancel(); diff --git a/src/test/Hangfire.Storage.SQLite.Test/ExpirationManagerFacts.cs b/src/test/Hangfire.Storage.SQLite.Test/ExpirationManagerFacts.cs index 772ca94..0a02dca 100644 --- a/src/test/Hangfire.Storage.SQLite.Test/ExpirationManagerFacts.cs +++ b/src/test/Hangfire.Storage.SQLite.Test/ExpirationManagerFacts.cs @@ -1,5 +1,4 @@ using Hangfire.Storage.SQLite.Entities; -using Hangfire.Storage.SQLite.Test.Utils; using System; using System.Collections.Generic; using System.Threading; @@ -7,17 +6,14 @@ namespace Hangfire.Storage.SQLite.Test { - public class ExpirationManagerFacts + public class ExpirationManagerFacts : SqliteInMemoryTestBase { - private readonly SQLiteStorage _storage; - private readonly CancellationToken _token; private static PersistentJobQueueProviderCollection _queueProviders; public ExpirationManagerFacts() { - _storage = ConnectionUtils.CreateStorage(); - _queueProviders = _storage.QueueProviders; + _queueProviders = Storage.QueueProviders; _token = new CancellationToken(true); } @@ -31,7 +27,7 @@ public void Ctor_ThrowsAnException_WhenStorageIsNull() [Fact] public void Execute_RemovesOutdatedRecords() { - using var connection = _storage.CreateAndOpenConnection(); + using var connection = Storage.CreateAndOpenConnection(); CreateExpirationEntries(connection, DateTime.UtcNow.AddMonths(-1)); var manager = CreateManager(); manager.Execute(_token); @@ -41,7 +37,7 @@ public void Execute_RemovesOutdatedRecords() [Fact] public void Execute_DoesNotRemoveEntries_WithNoExpirationTimeSet() { - using var connection = _storage.CreateAndOpenConnection(); + using var connection = Storage.CreateAndOpenConnection(); CreateExpirationEntries(connection, null); var manager = CreateManager(); manager.Execute(_token); @@ -51,7 +47,7 @@ public void Execute_DoesNotRemoveEntries_WithNoExpirationTimeSet() [Fact] public void Execute_DoesNotRemoveEntries_WithFreshExpirationTime() { - using var connection = _storage.CreateAndOpenConnection(); + using var connection = Storage.CreateAndOpenConnection(); CreateExpirationEntries(connection, DateTime.UtcNow.AddMonths(1)); var manager = CreateManager(); manager.Execute(_token); @@ -61,7 +57,7 @@ public void Execute_DoesNotRemoveEntries_WithFreshExpirationTime() [Fact] public void Execute_Processes_CounterTable() { - using var connection = _storage.CreateAndOpenConnection(); + using var connection = Storage.CreateAndOpenConnection(); connection.Database.Insert(new Counter { Id = Guid.NewGuid().ToString(), @@ -78,7 +74,7 @@ public void Execute_Processes_CounterTable() [Fact] public void Execute_Processes_JobTable() { - using var connection = _storage.CreateAndOpenConnection(); + using var connection = Storage.CreateAndOpenConnection(); connection.Database.Insert(new HangfireJob() { InvocationData = "", @@ -95,7 +91,7 @@ public void Execute_Processes_JobTable() [Fact] public void Execute_Processes_ListTable() { - using var connection = _storage.CreateAndOpenConnection(); + using var connection = Storage.CreateAndOpenConnection(); connection.Database.Insert(new HangfireList() { Key = "key", @@ -112,7 +108,7 @@ public void Execute_Processes_ListTable() [Fact] public void Execute_Processes_SetTable() { - using var connection = _storage.CreateAndOpenConnection(); + using var connection = Storage.CreateAndOpenConnection(); connection.Database.Insert(new Set { Key = "key", @@ -131,7 +127,7 @@ public void Execute_Processes_SetTable() [Fact] public void Execute_Processes_HashTable() { - using var connection = _storage.CreateAndOpenConnection(); + using var connection = Storage.CreateAndOpenConnection(); connection.Database.Insert(new Hash() { Key = "key", @@ -151,7 +147,7 @@ public void Execute_Processes_HashTable() [Fact] public void Execute_Processes_AggregatedCounterTable() { - using var connection = _storage.CreateAndOpenConnection(); + using var connection = Storage.CreateAndOpenConnection(); connection.Database.Insert(new AggregatedCounter { Key = "key", @@ -199,7 +195,7 @@ private static bool IsEntryExpired(HangfireDbContext connection) private ExpirationManager CreateManager() { - return new ExpirationManager(_storage); + return new ExpirationManager(Storage); } private static void Commit(HangfireDbContext connection, Action action) diff --git a/src/test/Hangfire.Storage.SQLite.Test/SQLiteConnectionFacts.GetFirstByLowestScoreFromSet.cs b/src/test/Hangfire.Storage.SQLite.Test/SQLiteConnectionFacts.GetFirstByLowestScoreFromSet.cs new file mode 100644 index 0000000..670b8d3 --- /dev/null +++ b/src/test/Hangfire.Storage.SQLite.Test/SQLiteConnectionFacts.GetFirstByLowestScoreFromSet.cs @@ -0,0 +1,156 @@ +using Hangfire.Storage.SQLite.Entities; +using System; +using Xunit; + +namespace Hangfire.Storage.SQLite.Test +{ + public partial class HangfireSQLiteConnectionFacts + { + [Fact] + public void GetFirstByLowestScoreFromSet_ThrowsAnException_WhenKeyIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.GetFirstByLowestScoreFromSet(null, 0, 1)); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact] + public void GetFirstByLowestScoreFromSet_ThrowsAnException_ToScoreIsLowerThanFromScore() + { + UseConnection((database, connection) => Assert.Throws( + () => connection.GetFirstByLowestScoreFromSet("key", 0, -1))); + } + + [Fact] + public void GetFirstByLowestScoreFromSet_ReturnsNull_WhenTheKeyDoesNotExist() + { + UseConnection((database, connection) => + { + var result = connection.GetFirstByLowestScoreFromSet( + "key", 0, 1); + + Assert.Null(result); + }); + } + + [Fact] + public void GetFirstByLowestScoreFromSet_ReturnsTheValueWithTheLowestScore() + { + UseConnection((database, connection) => + { + database.Database.Insert(new Set + { + Key = "key", + Score = 1.0m, + Value = "1.0" + }); + database.Database.Insert(new Set + { + Key = "key", + Score = -1.0m, + Value = "-1.0" + }); + database.Database.Insert(new Set + { + Key = "key", + Score = -5.0m, + Value = "-5.0" + }); + database.Database.Insert(new Set + { + Key = "another-key", + Score = -2.0m, + Value = "-2.0" + }); + + var result = connection.GetFirstByLowestScoreFromSet("key", -1.0, 3.0); + + Assert.Equal("-1.0", result); + }); + } + + [Fact] + [Trait("Feature", "BatchedGetFirstByLowestScoreFromSet")] + public void Batch_GetFirstByLowestScoreFromSet_ThrowsAnException_WhenKeyIsNull() + { + UseConnection((database, connection) => + { + var exception = Assert.Throws( + () => connection.GetFirstByLowestScoreFromSet(null, 0, 1, 1)); + + Assert.Equal("key", exception.ParamName); + }); + } + + [Fact] + [Trait("Feature", "BatchedGetFirstByLowestScoreFromSet")] + public void Batch_GetFirstByLowestScoreFromSet_ThrowsAnException_ToScoreIsLowerThanFromScore() + { + UseConnection((database, connection) => Assert.Throws( + () => connection.GetFirstByLowestScoreFromSet("key", 0, -1, 1))); + } + + [Fact] + [Trait("Feature", "BatchedGetFirstByLowestScoreFromSet")] + public void Batch_GetFirstByLowestScoreFromSet_ReturnsNull_WhenTheKeyDoesNotExist() + { + UseConnection((database, connection) => + { + var result = connection.GetFirstByLowestScoreFromSet( + "key", 0, 1, 1); + + Assert.Empty(result); + }); + } + + [Fact] + [Trait("Feature", "BatchedGetFirstByLowestScoreFromSet")] + public void Batch_GetFirstByLowestScoreFromSet_ReturnsTheValuesWithTheLowestScore() + { + UseConnection((database, connection) => + { + database.Database.InsertAll(new [] + { + new Set + { + Key = "key", + Score = 1.0m, + Value = "1.0" + }, + new Set + { + Key = "key", + Score = -1.0m, + Value = "-1.0" + }, + new Set + { + Key = "key", + Score = -4.0m, + Value = "-4.0" + }, + new Set + { + Key = "key", + Score = -5.0m, + Value = "-5.0" + }, + new Set + { + Key = "another-key", + Score = -2.0m, + Value = "-2.0" + } + }, typeof(Set)); + + var result = connection.GetFirstByLowestScoreFromSet("key", -5.0, 3.0, count: 3); + + Assert.Equal(new []{ "-5.0", "-4.0", "-1.0" }, result); + }); + } + } +} diff --git a/src/test/Hangfire.Storage.SQLite.Test/SQLiteConnectionFacts.GetSetCount.cs b/src/test/Hangfire.Storage.SQLite.Test/SQLiteConnectionFacts.GetSetCount.cs new file mode 100644 index 0000000..15c65e7 --- /dev/null +++ b/src/test/Hangfire.Storage.SQLite.Test/SQLiteConnectionFacts.GetSetCount.cs @@ -0,0 +1,137 @@ +using Hangfire.Storage.SQLite.Entities; +using System; +using Xunit; + +namespace Hangfire.Storage.SQLite.Test +{ + public partial class HangfireSQLiteConnectionFacts + { + [Fact] + [Trait("Feature", "ExtendedApi")] + public void GetSetCount_ThrowsAnException_WhenKeyIsNull() + { + UseConnection((database, connection) => + { + Assert.Throws( + () => connection.GetSetCount(null)); + }); + } + + [Fact] + [Trait("Feature", "ExtendedApi")] + public void GetSetCount_ReturnsZero_WhenSetDoesNotExist() + { + UseConnection((database, connection) => + { + var result = connection.GetSetCount("my-set"); + Assert.Equal(0, result); + }); + } + + [Fact] + [Trait("Feature", "ExtendedApi")] + public void GetSetCount_ReturnsNumberOfElements_InASet() + { + UseConnection((database, connection) => + { + database.Database.Insert(new Set + { + Key = "set-1", + Value = "value-1" + }); + database.Database.Insert(new Set + { + Key = "set-2", + Value = "value-1" + }); + database.Database.Insert(new Set + { + Key = "set-1", + Value = "value-2" + }); + + var result = connection.GetSetCount("set-1"); + + Assert.Equal(2, result); + }); + } + + [Fact] + [Trait("Feature", "Connection.GetSetCount.Limited")] + public void Limited_GetSetCount_ThrowsAnException_WhenKeyIsNull() + { + UseConnection((database, connection) => + { + Assert.Throws( + () => connection.GetSetCount(null)); + }); + } + + [Fact] + [Trait("Feature", "Connection.GetSetCount.Limited")] + public void Limited_GetSetCount_ReturnsZero_WhenSetDoesNotExist() + { + UseConnection((database, connection) => + { + var result = connection.GetSetCount(new []{"my-set"}, 1); + Assert.Equal(0, result); + }); + } + + [Fact] + [Trait("Feature", "Connection.GetSetCount.Limited")] + public void Limited_GetSetCount_Returns_UpToLimit_NumberOfElements_InASet() + { + UseConnection((database, connection) => + { + database.Database.Insert(new Set + { + Key = "set-1", + Value = "value-1" + }); + database.Database.Insert(new Set + { + Key = "set-2", + Value = "value-1" + }); + database.Database.Insert(new Set + { + Key = "set-1", + Value = "value-2" + }); + + var result = connection.GetSetCount(new []{"set-1", "set-2"}, 2); + + Assert.Equal(2, result); + }); + } + + [Fact] + [Trait("Feature", "Connection.GetSetCount.Limited")] + public void Limited_GetSetCount_Returns_AllCounts_IfLimitHighEnough() + { + UseConnection((database, connection) => + { + database.Database.Insert(new Set + { + Key = "set-1", + Value = "value-1" + }); + database.Database.Insert(new Set + { + Key = "set-2", + Value = "value-1" + }); + database.Database.Insert(new Set + { + Key = "set-1", + Value = "value-2" + }); + + var result = connection.GetSetCount(new []{"set-1", "set-2"}, 99999); + + Assert.Equal(3, result); + }); + } + } +} diff --git a/src/test/Hangfire.Storage.SQLite.Test/SQLiteConnectionFacts.cs b/src/test/Hangfire.Storage.SQLite.Test/SQLiteConnectionFacts.cs index 03d301f..1e9bf0c 100644 --- a/src/test/Hangfire.Storage.SQLite.Test/SQLiteConnectionFacts.cs +++ b/src/test/Hangfire.Storage.SQLite.Test/SQLiteConnectionFacts.cs @@ -13,7 +13,7 @@ namespace Hangfire.Storage.SQLite.Test { - public class HangfireSQLiteConnectionFacts + public partial class HangfireSQLiteConnectionFacts : SqliteInMemoryTestBase { private readonly Mock _queue; private readonly PersistentJobQueueProviderCollection _providers; @@ -41,7 +41,7 @@ public void Ctor_ThrowsAnException_WhenConnectionIsNull() public void Ctor_ThrowsAnException_WhenProvidersCollectionIsNull() { var exception = Assert.Throws( - () => new HangfireSQLiteConnection(ConnectionUtils.CreateConnection(), null)); + () => new HangfireSQLiteConnection(Storage.CreateAndOpenConnection(), null)); Assert.Equal("queueProviders", exception.ParamName); } @@ -117,7 +117,7 @@ public void CreateExpiredJob_ThrowsAnException_WhenParametersCollectionIsNull() { var exception = Assert.Throws( () => connection.CreateExpiredJob( - Job.FromExpression(() => SampleMethod("hello")), + Job.FromExpression(() => TestJobClass.SampleMethod("hello")), null, DateTime.UtcNow, TimeSpan.Zero)); @@ -133,7 +133,7 @@ public void CreateExpiredJob_CreatesAJobInTheStorage_AndSetsItsParameters() { var createdAt = new DateTime(2012, 12, 12, 0, 0, 0, 0, DateTimeKind.Utc); var jobId = connection.CreateExpiredJob( - Job.FromExpression(() => SampleMethod("Hello")), + Job.FromExpression(() => TestJobClass.SampleMethod("Hello")), new Dictionary { { "Key1", "Value1" }, { "Key2", "Value2" } }, createdAt, TimeSpan.FromDays(1)); @@ -152,7 +152,7 @@ public void CreateExpiredJob_CreatesAJobInTheStorage_AndSetsItsParameters() invocationData.Arguments = databaseJob.Arguments; var job = invocationData.DeserializeJob(); - Assert.Equal(typeof(HangfireSQLiteConnectionFacts), job.Type); + Assert.Equal(typeof(TestJobClass), job.Type); Assert.Equal("SampleMethod", job.Method.Name); Assert.Equal("Hello", job.Args[0]); @@ -193,7 +193,7 @@ public void GetJobData_ReturnsResult_WhenJobExists() { UseConnection((database, connection) => { - var job = Job.FromExpression(() => SampleMethod("wrong")); + var job = Job.FromExpression(() => TestJobClass.SampleMethod("wrong")); var hangfireJob = new HangfireJob { @@ -471,73 +471,6 @@ public void GetParameter_ReturnsParameterValue_WhenJobExists() }); } - [Fact] - public void GetFirstByLowestScoreFromSet_ThrowsAnException_WhenKeyIsNull() - { - UseConnection((database, connection) => - { - var exception = Assert.Throws( - () => connection.GetFirstByLowestScoreFromSet(null, 0, 1)); - - Assert.Equal("key", exception.ParamName); - }); - } - - [Fact] - public void GetFirstByLowestScoreFromSet_ThrowsAnException_ToScoreIsLowerThanFromScore() - { - UseConnection((database, connection) => Assert.Throws( - () => connection.GetFirstByLowestScoreFromSet("key", 0, -1))); - } - - [Fact] - public void GetFirstByLowestScoreFromSet_ReturnsNull_WhenTheKeyDoesNotExist() - { - UseConnection((database, connection) => - { - var result = connection.GetFirstByLowestScoreFromSet( - "key", 0, 1); - - Assert.Null(result); - }); - } - - [Fact] - public void GetFirstByLowestScoreFromSet_ReturnsTheValueWithTheLowestScore() - { - UseConnection((database, connection) => - { - database.Database.Insert(new Set - { - Key = "key", - Score = 1.0m, - Value = "1.0" - }); - database.Database.Insert(new Set - { - Key = "key", - Score = -1.0m, - Value = "-1.0" - }); - database.Database.Insert(new Set - { - Key = "key", - Score = -5.0m, - Value = "-5.0" - }); - database.Database.Insert(new Set - { - Key = "another-key", - Score = -2.0m, - Value = "-2.0" - }); - - var result = connection.GetFirstByLowestScoreFromSet("key", -1.0, 3.0); - - Assert.Equal("-1.0", result); - }); - } - [Fact] public void AnnounceServer_ThrowsAnException_WhenServerIdIsNull() { @@ -771,6 +704,28 @@ public void GetAllItemsFromSet_ReturnsAllItems_InCorrectOrder() }); } + [Theory] + [InlineData("v1", true)] + [InlineData("v2", false)] + public void GetSetContains_Returns_True_If_Value_Found(string value, bool contains) + { + UseConnection((database, connection) => + { + // Arrange + database.Database.Insert(new Set + { + Key = "list-1", + Value = "v1" + }); + + // Act + var result = connection.GetSetContains("list-1", value); + + // Assert + Assert.Equal(contains, result); + }); + } + [Fact] public void SetRangeInHash_ThrowsAnException_WhenKeyIsNull() { @@ -867,53 +822,6 @@ public void GetAllEntriesFromHash_ReturnsAllKeysAndTheirValues() }); } - [Fact] - public void GetSetCount_ThrowsAnException_WhenKeyIsNull() - { - UseConnection((database, connection) => - { - Assert.Throws( - () => connection.GetSetCount(null)); - }); - } - - [Fact] - public void GetSetCount_ReturnsZero_WhenSetDoesNotExist() - { - UseConnection((database, connection) => - { - var result = connection.GetSetCount("my-set"); - Assert.Equal(0, result); - }); - } - - [Fact] - public void GetSetCount_ReturnsNumberOfElements_InASet() - { - UseConnection((database, connection) => - { - database.Database.Insert(new Set - { - Key = "set-1", - Value = "value-1" - }); - database.Database.Insert(new Set - { - Key = "set-2", - Value = "value-1" - }); - database.Database.Insert(new Set - { - Key = "set-1", - Value = "value-2" - }); - - var result = connection.GetSetCount("set-1"); - - Assert.Equal(2, result); - }); - } - [Fact] public void GetRangeFromSet_ThrowsAnException_WhenKeyIsNull() { @@ -1523,13 +1431,34 @@ public void GetAllItemsFromList_ReturnsAllItemsFromAGivenList_InCorrectOrder() Assert.Equal(new[] { "5", "4", "3", "1" }, result); }); } + + [Fact] + public void GetUtcDateTime_IsSupported() + { + UseConnection((_, conn) => + { + var start = DateTime.UtcNow; + + // Act + var utcDateTime = conn.GetUtcDateTime(); + var end = DateTime.UtcNow; + + Assert.InRange(utcDateTime, start, end); + }); + } + private void UseConnection(Action action) { - using var database = ConnectionUtils.CreateConnection(); + using var database = Storage.CreateAndOpenConnection(); using var connection = new HangfireSQLiteConnection(database, _providers); action(database, connection); } + + } + + public class TestJobClass + { public static void SampleMethod(string arg) { Debug.WriteLine(arg); diff --git a/src/test/Hangfire.Storage.SQLite.Test/SQLiteDistributedLockFacts.cs b/src/test/Hangfire.Storage.SQLite.Test/SQLiteDistributedLockFacts.cs index f536ab2..5056b61 100644 --- a/src/test/Hangfire.Storage.SQLite.Test/SQLiteDistributedLockFacts.cs +++ b/src/test/Hangfire.Storage.SQLite.Test/SQLiteDistributedLockFacts.cs @@ -9,7 +9,7 @@ namespace Hangfire.Storage.SQLite.Test { - public class SQLiteDistributedLockFacts + public class SQLiteDistributedLockFacts : SqliteInMemoryTestBase { [Fact] public void Ctor_ThrowsAnException_WhenResourceIsNull() @@ -117,7 +117,6 @@ public void Ctor_ThrowsAnException_WhenResourceIsLocked() [Fact] public void Ctor_WaitForLock_SignaledAtLockRelease() { - var storage = ConnectionUtils.CreateStorage(); using var mre = new ManualResetEventSlim(); var t = NewBackgroundThread(() => { @@ -128,7 +127,7 @@ public void Ctor_WaitForLock_SignaledAtLockRelease() mre.Set(); Thread.Sleep(TimeSpan.FromSeconds(3)); } - }, storage); + }); }); UseConnection(database => { @@ -145,7 +144,7 @@ public void Ctor_WaitForLock_SignaledAtLockRelease() } t.Join(); - }, storage); + }); } [Fact] @@ -306,15 +305,15 @@ private async Task WaitForHeartBeat(SQLiteDistributedLock slock, TimeSpan } } - private static void UseConnection(Action action, SQLiteStorage storage = null) + private void UseConnection(Action action) { - using var connection = storage?.CreateAndOpenConnection() ?? ConnectionUtils.CreateConnection(); + using var connection = Storage.CreateAndOpenConnection(); action(connection); } - private static async Task UseConnectionAsync(Func func, SQLiteStorage storage = null) + private async Task UseConnectionAsync(Func func) { - using var connection = storage?.CreateAndOpenConnection() ?? ConnectionUtils.CreateConnection(); + using var connection = Storage.CreateAndOpenConnection(); await func(connection); } } diff --git a/src/test/Hangfire.Storage.SQLite.Test/SQLiteFetchedJobFacts.cs b/src/test/Hangfire.Storage.SQLite.Test/SQLiteFetchedJobFacts.cs index ee90fd3..0a23ad2 100644 --- a/src/test/Hangfire.Storage.SQLite.Test/SQLiteFetchedJobFacts.cs +++ b/src/test/Hangfire.Storage.SQLite.Test/SQLiteFetchedJobFacts.cs @@ -1,12 +1,11 @@ using Hangfire.Storage.SQLite.Entities; -using Hangfire.Storage.SQLite.Test.Utils; using System; using System.Linq; using Xunit; namespace Hangfire.Storage.SQLite.Test { - public class SQLiteFetchedJobFacts + public class SQLiteFetchedJobFacts : SqliteInMemoryTestBase { private const int JobId = 0; private const string Queue = "queue"; @@ -153,9 +152,9 @@ private static int CreateJobQueueRecord(HangfireDbContext connection, int jobId, return jobQueue.Id; } - private static void UseConnection(Action action) + private void UseConnection(Action action) { - using var connection = ConnectionUtils.CreateConnection(); + using var connection = Storage.CreateAndOpenConnection(); action(connection); } } diff --git a/src/test/Hangfire.Storage.SQLite.Test/SQLiteJobQueueFacts.cs b/src/test/Hangfire.Storage.SQLite.Test/SQLiteJobQueueFacts.cs index 4b0986c..0f50b36 100644 --- a/src/test/Hangfire.Storage.SQLite.Test/SQLiteJobQueueFacts.cs +++ b/src/test/Hangfire.Storage.SQLite.Test/SQLiteJobQueueFacts.cs @@ -1,5 +1,4 @@ using Hangfire.Storage.SQLite.Entities; -using Hangfire.Storage.SQLite.Test.Utils; using System; using System.Linq; using System.Threading; @@ -7,7 +6,7 @@ namespace Hangfire.Storage.SQLite.Test { - public class SQLiteJobQueueFacts + public class SQLiteJobQueueFacts : SqliteInMemoryTestBase { private static readonly string[] DefaultQueues = { "default" }; @@ -331,9 +330,9 @@ private static SQLiteJobQueue CreateJobQueue(HangfireDbContext connection) return new SQLiteJobQueue(connection, new SQLiteStorageOptions()); } - private static void UseConnection(Action action) + private void UseConnection(Action action) { - using var connection = ConnectionUtils.CreateConnection(); + using var connection = Storage.CreateAndOpenConnection(); action(connection); } } diff --git a/src/test/Hangfire.Storage.SQLite.Test/SQLiteMonitoringApiFacts.cs b/src/test/Hangfire.Storage.SQLite.Test/SQLiteMonitoringApiFacts.cs index 4bc9e9e..bfb8581 100644 --- a/src/test/Hangfire.Storage.SQLite.Test/SQLiteMonitoringApiFacts.cs +++ b/src/test/Hangfire.Storage.SQLite.Test/SQLiteMonitoringApiFacts.cs @@ -1,7 +1,6 @@ using Hangfire.Common; using Hangfire.States; using Hangfire.Storage.SQLite.Entities; -using Hangfire.Storage.SQLite.Test.Utils; using Moq; using Newtonsoft.Json; using System; @@ -10,7 +9,7 @@ namespace Hangfire.Storage.SQLite.Test { - public class SQLiteMonitoringApiFacts + public class SQLiteMonitoringApiFacts : SqliteInMemoryTestBase { private const string DefaultQueue = "default"; private const string FetchedStateName = "Fetched"; @@ -282,9 +281,8 @@ public void ProcessingJobs_ReturnsProcessingJobsOnly_WhenMultipleJobsExistsInPro private void UseMonitoringApi(Action action) { - using var storage = ConnectionUtils.CreateStorage(); - var connection = new SQLiteMonitoringApi(storage, _providers); - using var dbContext = storage.CreateAndOpenConnection(); + var connection = new SQLiteMonitoringApi(Storage, _providers); + using var dbContext = Storage.CreateAndOpenConnection(); action(dbContext, connection); } diff --git a/src/test/Hangfire.Storage.SQLite.Test/SQLiteWriteOnlyTransactionFacts.cs b/src/test/Hangfire.Storage.SQLite.Test/SQLiteWriteOnlyTransactionFacts.cs index f74ea12..71e1b86 100644 --- a/src/test/Hangfire.Storage.SQLite.Test/SQLiteWriteOnlyTransactionFacts.cs +++ b/src/test/Hangfire.Storage.SQLite.Test/SQLiteWriteOnlyTransactionFacts.cs @@ -10,7 +10,7 @@ namespace Hangfire.Storage.SQLite.Test { - public class SQLiteWriteOnlyTransactionFacts + public class SQLiteWriteOnlyTransactionFacts : SqliteInMemoryTestBase { private readonly PersistentJobQueueProviderCollection _queueProviders; @@ -34,7 +34,7 @@ public void Ctor_ThrowsAnException_IfConnectionIsNull() [Fact] public void Ctor_ThrowsAnException_IfProvidersCollectionIsNull() { - var exception = Assert.Throws(() => new SQLiteWriteOnlyTransaction(ConnectionUtils.CreateConnection(), null)); + var exception = Assert.Throws(() => new SQLiteWriteOnlyTransaction(Storage.CreateAndOpenConnection(), null)); Assert.Equal("queueProviders", exception.ParamName); } @@ -889,6 +889,51 @@ public void RemoveSet_ClearsTheSetData() }); } + [Fact] + public void AcquireDistributedLock_Returns_Lock() + { + UseConnection(database => + { + using (SQLiteWriteOnlyTransaction transaction = new SQLiteWriteOnlyTransaction(database, _queueProviders)) + { + transaction.AcquireDistributedLock("key", TimeSpan.Zero); + Assert.NotEmpty(transaction._acquiredLocks); + } + }); + } + + [Fact] + public void Committing_Transaction_Frees_Locks() + { + UseConnection(database => + { + using (SQLiteWriteOnlyTransaction transaction = new SQLiteWriteOnlyTransaction(database, _queueProviders)) + { + transaction.AcquireDistributedLock("key", TimeSpan.Zero); + Assert.NotEmpty(transaction._acquiredLocks); + + transaction.Commit(); + Assert.Empty(transaction._acquiredLocks); + } + }); + } + + [Fact] + public void Disposing_Transaction_Frees_Locks() + { + UseConnection(database => + { + using (SQLiteWriteOnlyTransaction transaction = new SQLiteWriteOnlyTransaction(database, _queueProviders)) + { + transaction.AcquireDistributedLock("key", TimeSpan.Zero); + Assert.NotEmpty(transaction._acquiredLocks); + + transaction.Dispose(); + Assert.Empty(transaction._acquiredLocks); + } + }); + } + private static HangfireJob GetTestJob(HangfireDbContext database, int jobId) { return database.HangfireJobRepository.FirstOrDefault(x => x.Id == jobId); @@ -911,7 +956,7 @@ private static dynamic GetTestHash(HangfireDbContext database, string key) private void UseConnection(Action action) { - using var connection = ConnectionUtils.CreateConnection(); + using var connection = Storage.CreateAndOpenConnection(); action(connection); } diff --git a/src/test/Hangfire.Storage.SQLite.Test/SqliteInMemoryTestBase.cs b/src/test/Hangfire.Storage.SQLite.Test/SqliteInMemoryTestBase.cs new file mode 100644 index 0000000..5d06047 --- /dev/null +++ b/src/test/Hangfire.Storage.SQLite.Test/SqliteInMemoryTestBase.cs @@ -0,0 +1,25 @@ +using System; +using Hangfire.Storage.SQLite.Test.Utils; + +namespace Hangfire.Storage.SQLite.Test; + +public class SqliteInMemoryTestBase : IDisposable +{ + protected SQLiteStorage Storage { get; } = ConnectionUtils.CreateStorage(); + /// + /// This connection ensures that an in-memory database + /// will stay alive until the test finishes + /// + private readonly IDisposable _rootingInMemoryConnection; + + protected SqliteInMemoryTestBase() + { + _rootingInMemoryConnection = Storage.CreateAndOpenConnection(); + } + + public virtual void Dispose() + { + _rootingInMemoryConnection?.Dispose(); + Storage?.Dispose(); + } +} \ No newline at end of file diff --git a/src/test/Hangfire.Storage.SQLite.Test/Utils/ConnectionUtils.cs b/src/test/Hangfire.Storage.SQLite.Test/Utils/ConnectionUtils.cs index 28ba152..d286652 100644 --- a/src/test/Hangfire.Storage.SQLite.Test/Utils/ConnectionUtils.cs +++ b/src/test/Hangfire.Storage.SQLite.Test/Utils/ConnectionUtils.cs @@ -12,17 +12,17 @@ public static SQLiteStorage CreateStorage() return CreateStorage(storageOptions); } + /// + /// For multi-threaded tests make sure to call per Thread. + /// For proper testing always ensure that at least ONE in-memory connection is alive + /// so that the in-memory database will not be deleted unexpectedly + /// public static SQLiteStorage CreateStorage(SQLiteStorageOptions storageOptions) { - // See SQLite Docs: https://www.sqlite.org/c3ref/c_open_autoproxy.html - // const SQLiteOpenFlags SQLITE_OPEN_MEMORY = (SQLiteOpenFlags) 0x00000080; - const SQLiteOpenFlags SQLITE_OPEN_URI = (SQLiteOpenFlags) 0x00000040; const SQLiteOpenFlags flags = // open the database in memory - // SQLITE_OPEN_MEMORY | - // SQLiteOpenFlags.SharedCache | - // for whatever reason, if we don't use URI-mode, - // shared in-memory databases dont work properly. - SQLITE_OPEN_URI | + // URI Mode is required to have multiple separate-inmemory + // databases, and allow shared access + (SQLiteOpenFlags) SQLitePCL.raw.SQLITE_OPEN_URI | // open the database in read/write mode SQLiteOpenFlags.ReadWrite | // create the database if it doesn't exist @@ -40,15 +40,5 @@ public static SQLiteStorage CreateStorage(SQLiteStorageOptions storageOptions) }), storageOptions); } - - /// - /// Only use this if you have a single thread. - /// For multi-threaded tests, use directly and - /// then call per Thread. - /// - public static HangfireDbContext CreateConnection() - { - return CreateStorage().CreateAndOpenConnection(); - } } } \ No newline at end of file